Skip to content

Commit 7d3145b

Browse files
committed
fix: stabilize usage billing calculations
1 parent 58541c8 commit 7d3145b

4 files changed

Lines changed: 374 additions & 43 deletions

File tree

database/billing.go

Lines changed: 235 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,260 @@
11
package database
22

3+
import "strings"
4+
35
// ModelPricing 模型价格配置(每百万 token 的价格,单位:美元)
46
type ModelPricing struct {
5-
InputPricePerMToken float64 // 输入价格(美元/百万token)
6-
OutputPricePerMToken float64 // 输出价格(美元/百万token)
7+
InputPricePerMToken float64 // 输入价格(美元/百万token)
8+
InputPricePerMTokenPriority float64 // priority service tier 输入价格
9+
OutputPricePerMToken float64 // 输出价格(美元/百万token)
10+
OutputPricePerMTokenPriority float64 // priority service tier 输出价格
11+
CacheReadPricePerMToken float64 // 缓存命中输入价格
12+
CacheReadPricePerMTokenPriority float64 // priority service tier 缓存命中输入价格
713
}
814

9-
// getModelPricing 获取模型价格配置
10-
// 价格参考 OpenAI 官方定价:https://openai.com/api/pricing/
11-
func getModelPricing(model string) *ModelPricing {
12-
// 硬编码价格表(简化版,仅包含常用模型)
13-
pricingTable := map[string]*ModelPricing{
14-
// GPT-4 系列
15-
"gpt-4": {InputPricePerMToken: 30.0, OutputPricePerMToken: 60.0},
16-
"gpt-4-turbo": {InputPricePerMToken: 10.0, OutputPricePerMToken: 30.0},
17-
"gpt-4o": {InputPricePerMToken: 2.5, OutputPricePerMToken: 10.0},
18-
"gpt-4o-mini": {InputPricePerMToken: 0.15, OutputPricePerMToken: 0.6},
15+
type modelPricingRule struct {
16+
model string
17+
pricing ModelPricing
18+
}
1919

20-
// GPT-3.5 系列
21-
"gpt-3.5-turbo": {InputPricePerMToken: 0.5, OutputPricePerMToken: 1.5},
20+
var (
21+
defaultModelPricing = &ModelPricing{InputPricePerMToken: 1.0, OutputPricePerMToken: 2.0}
2222

23-
// Claude 系列(Anthropic 定价)
24-
"claude-opus-4": {InputPricePerMToken: 15.0, OutputPricePerMToken: 75.0},
25-
"claude-sonnet-4": {InputPricePerMToken: 3.0, OutputPricePerMToken: 15.0},
26-
"claude-haiku-4": {InputPricePerMToken: 0.25, OutputPricePerMToken: 1.25},
23+
modelPricingRules = []modelPricingRule{
24+
// Codex/GPT-5 系列,参考 sub2api 的本地 fallback 定价。
25+
{model: "gpt-5.4-mini", pricing: ModelPricing{InputPricePerMToken: 0.75, OutputPricePerMToken: 4.5, CacheReadPricePerMToken: 0.075}},
26+
{model: "gpt-5.4-nano", pricing: ModelPricing{InputPricePerMToken: 0.2, OutputPricePerMToken: 1.25, CacheReadPricePerMToken: 0.02}},
27+
{model: "gpt-5.4", pricing: ModelPricing{
28+
InputPricePerMToken: 2.5,
29+
InputPricePerMTokenPriority: 5.0,
30+
OutputPricePerMToken: 15.0,
31+
OutputPricePerMTokenPriority: 30.0,
32+
CacheReadPricePerMToken: 0.25,
33+
CacheReadPricePerMTokenPriority: 0.5,
34+
}},
35+
{model: "gpt-5.3-codex-spark", pricing: ModelPricing{
36+
InputPricePerMToken: 1.5,
37+
InputPricePerMTokenPriority: 3.0,
38+
OutputPricePerMToken: 12.0,
39+
OutputPricePerMTokenPriority: 24.0,
40+
CacheReadPricePerMToken: 0.15,
41+
CacheReadPricePerMTokenPriority: 0.3,
42+
}},
43+
{model: "gpt-5.3-codex", pricing: ModelPricing{
44+
InputPricePerMToken: 1.5,
45+
InputPricePerMTokenPriority: 3.0,
46+
OutputPricePerMToken: 12.0,
47+
OutputPricePerMTokenPriority: 24.0,
48+
CacheReadPricePerMToken: 0.15,
49+
CacheReadPricePerMTokenPriority: 0.3,
50+
}},
51+
{model: "gpt-5.2", pricing: ModelPricing{
52+
InputPricePerMToken: 1.75,
53+
InputPricePerMTokenPriority: 3.5,
54+
OutputPricePerMToken: 14.0,
55+
OutputPricePerMTokenPriority: 28.0,
56+
CacheReadPricePerMToken: 0.175,
57+
CacheReadPricePerMTokenPriority: 0.35,
58+
}},
2759

28-
// 默认价格(未知模型)
29-
"default": {InputPricePerMToken: 1.0, OutputPricePerMToken: 2.0},
60+
// GPT-4 系列。保持最具体模型优先,避免 gpt-4o-mini 被 gpt-4o/gpt-4 抢先匹配。
61+
{model: "gpt-4o-mini", pricing: ModelPricing{InputPricePerMToken: 0.15, OutputPricePerMToken: 0.6}},
62+
{model: "gpt-4o", pricing: ModelPricing{InputPricePerMToken: 2.5, OutputPricePerMToken: 10.0}},
63+
{model: "gpt-4-turbo", pricing: ModelPricing{InputPricePerMToken: 10.0, OutputPricePerMToken: 30.0}},
64+
{model: "gpt-4", pricing: ModelPricing{InputPricePerMToken: 30.0, OutputPricePerMToken: 60.0}},
65+
{model: "gpt-3.5-turbo", pricing: ModelPricing{InputPricePerMToken: 0.5, OutputPricePerMToken: 1.5}},
3066
}
67+
)
3168

32-
// 查找模型价格
33-
if pricing, ok := pricingTable[model]; ok {
69+
// getModelPricing 获取模型价格配置
70+
// 优先使用确定性的模型族匹配,避免 Go map 迭代顺序导致重叠前缀随机命中。
71+
func getModelPricing(model string) *ModelPricing {
72+
normalized := normalizeBillingModelName(model)
73+
if pricing := claudeFamilyPricing(normalized); pricing != nil {
3474
return pricing
3575
}
36-
37-
// 模糊匹配(处理带版本号的模型名)
38-
for key, pricing := range pricingTable {
39-
if len(model) > len(key) && model[:len(key)] == key {
40-
return pricing
41-
}
76+
if pricing := geminiFamilyPricing(normalized); pricing != nil {
77+
return pricing
4278
}
43-
44-
// 返回默认价格
45-
return pricingTable["default"]
79+
if codexModel, ok := normalizeCodexBillingModel(normalized); ok {
80+
normalized = codexModel
81+
}
82+
if pricing := modelRulePricing(normalized); pricing != nil {
83+
return pricing
84+
}
85+
return defaultModelPricing
4686
}
4787

4888
// calculateCost 计算使用费用
4989
// inputTokens: 输入 token 数量
5090
// outputTokens: 输出 token 数量
5191
// model: 模型名称
5292
// 返回:账号计费金额(美元)
53-
func calculateCost(inputTokens, outputTokens int, model string) float64 {
93+
func calculateCost(inputTokens, outputTokens, cachedTokens int, model string, serviceTier string) float64 {
5494
pricing := getModelPricing(model)
95+
inputPrice := pricing.InputPricePerMToken
96+
outputPrice := pricing.OutputPricePerMToken
97+
cacheReadPrice := pricing.CacheReadPricePerMToken
5598

56-
// 计算费用(token 数量 / 1,000,000 * 单价)
57-
inputCost := float64(inputTokens) / 1000000.0 * pricing.InputPricePerMToken
58-
outputCost := float64(outputTokens) / 1000000.0 * pricing.OutputPricePerMToken
99+
tierMultiplier := serviceTierCostMultiplier(serviceTier)
100+
if usePriorityPricing(serviceTier, pricing) {
101+
tierMultiplier = 1
102+
if pricing.InputPricePerMTokenPriority > 0 {
103+
inputPrice = pricing.InputPricePerMTokenPriority
104+
}
105+
if pricing.OutputPricePerMTokenPriority > 0 {
106+
outputPrice = pricing.OutputPricePerMTokenPriority
107+
}
108+
if pricing.CacheReadPricePerMTokenPriority > 0 {
109+
cacheReadPrice = pricing.CacheReadPricePerMTokenPriority
110+
}
111+
}
112+
113+
if cachedTokens < 0 {
114+
cachedTokens = 0
115+
}
116+
if cachedTokens > inputTokens {
117+
cachedTokens = inputTokens
118+
}
119+
120+
uncachedInputTokens := inputTokens
121+
if cacheReadPrice > 0 {
122+
uncachedInputTokens = inputTokens - cachedTokens
123+
}
124+
125+
inputCost := float64(uncachedInputTokens) / 1000000.0 * inputPrice
126+
cacheReadCost := float64(cachedTokens) / 1000000.0 * cacheReadPrice
127+
outputCost := float64(outputTokens) / 1000000.0 * outputPrice
128+
129+
return (inputCost + cacheReadCost + outputCost) * tierMultiplier
130+
}
131+
132+
func normalizeBillingModelName(model string) string {
133+
model = strings.ToLower(strings.TrimSpace(model))
134+
model = strings.TrimLeft(model, "/")
135+
model = strings.TrimPrefix(model, "models/")
136+
model = strings.TrimPrefix(model, "publishers/google/models/")
137+
if idx := strings.LastIndex(model, "/publishers/google/models/"); idx != -1 {
138+
model = model[idx+len("/publishers/google/models/"):]
139+
}
140+
if idx := strings.LastIndex(model, "/models/"); idx != -1 {
141+
model = model[idx+len("/models/"):]
142+
} else if idx := strings.LastIndex(model, "/"); idx != -1 {
143+
model = model[idx+1:]
144+
}
145+
return strings.TrimLeft(model, "/")
146+
}
147+
148+
func normalizeCodexBillingModel(model string) (string, bool) {
149+
compact := strings.NewReplacer(" ", "-", "_", "-").Replace(model)
150+
switch {
151+
case strings.Contains(compact, "gpt-5.4-mini") || strings.Contains(compact, "gpt5-4-mini") || strings.Contains(compact, "gpt5.4-mini"):
152+
return "gpt-5.4-mini", true
153+
case strings.Contains(compact, "gpt-5.4-nano") || strings.Contains(compact, "gpt5-4-nano") || strings.Contains(compact, "gpt5.4-nano"):
154+
return "gpt-5.4-nano", true
155+
case strings.Contains(compact, "gpt-5.4") || strings.Contains(compact, "gpt5-4") || strings.Contains(compact, "gpt5.4"):
156+
return "gpt-5.4", true
157+
case strings.Contains(compact, "gpt-5.2") || strings.Contains(compact, "gpt5-2") || strings.Contains(compact, "gpt5.2"):
158+
return "gpt-5.2", true
159+
case strings.Contains(compact, "gpt-5.3-codex-spark") || strings.Contains(compact, "gpt5-3-codex-spark") || strings.Contains(compact, "gpt5.3-codex-spark"):
160+
return "gpt-5.3-codex-spark", true
161+
case strings.Contains(compact, "gpt-5.3-codex") || strings.Contains(compact, "gpt5-3-codex") || strings.Contains(compact, "gpt5.3-codex"):
162+
return "gpt-5.3-codex", true
163+
case strings.Contains(compact, "gpt-5.3") || strings.Contains(compact, "gpt5-3") || strings.Contains(compact, "gpt5.3"):
164+
return "gpt-5.3-codex", true
165+
case strings.Contains(compact, "codex"):
166+
return "gpt-5.3-codex", true
167+
case strings.Contains(compact, "gpt-5") || strings.Contains(compact, "gpt5"):
168+
return "gpt-5.4", true
169+
default:
170+
return "", false
171+
}
172+
}
173+
174+
func modelRulePricing(model string) *ModelPricing {
175+
bestIdx := -1
176+
bestLen := -1
177+
for i := range modelPricingRules {
178+
rule := modelPricingRules[i]
179+
if modelMatchesRule(model, rule.model) && len(rule.model) > bestLen {
180+
bestIdx = i
181+
bestLen = len(rule.model)
182+
}
183+
}
184+
if bestIdx == -1 {
185+
return nil
186+
}
187+
return &modelPricingRules[bestIdx].pricing
188+
}
189+
190+
func modelMatchesRule(model string, rule string) bool {
191+
if model == rule {
192+
return true
193+
}
194+
if !strings.HasPrefix(model, rule) {
195+
return false
196+
}
197+
if len(model) == len(rule) {
198+
return true
199+
}
200+
switch model[len(rule)] {
201+
case '-', '.', ':':
202+
return true
203+
default:
204+
return false
205+
}
206+
}
207+
208+
func claudeFamilyPricing(model string) *ModelPricing {
209+
switch {
210+
case strings.Contains(model, "opus"):
211+
if strings.Contains(model, "4.7") || strings.Contains(model, "4-7") ||
212+
strings.Contains(model, "4.6") || strings.Contains(model, "4-6") ||
213+
strings.Contains(model, "4.5") || strings.Contains(model, "4-5") {
214+
return &ModelPricing{InputPricePerMToken: 5.0, OutputPricePerMToken: 25.0}
215+
}
216+
return &ModelPricing{InputPricePerMToken: 15.0, OutputPricePerMToken: 75.0}
217+
case strings.Contains(model, "sonnet"):
218+
return &ModelPricing{InputPricePerMToken: 3.0, OutputPricePerMToken: 15.0}
219+
case strings.Contains(model, "haiku"):
220+
if strings.Contains(model, "3-5") || strings.Contains(model, "3.5") {
221+
return &ModelPricing{InputPricePerMToken: 1.0, OutputPricePerMToken: 5.0}
222+
}
223+
return &ModelPricing{InputPricePerMToken: 0.25, OutputPricePerMToken: 1.25}
224+
case strings.Contains(model, "claude"):
225+
return &ModelPricing{InputPricePerMToken: 3.0, OutputPricePerMToken: 15.0}
226+
default:
227+
return nil
228+
}
229+
}
230+
231+
func geminiFamilyPricing(model string) *ModelPricing {
232+
if strings.Contains(model, "gemini-3.1-pro") || strings.Contains(model, "gemini-3-1-pro") {
233+
return &ModelPricing{InputPricePerMToken: 2.0, OutputPricePerMToken: 12.0}
234+
}
235+
return nil
236+
}
237+
238+
func usePriorityPricing(serviceTier string, pricing *ModelPricing) bool {
239+
if normalizeServiceTier(serviceTier) != "priority" {
240+
return false
241+
}
242+
return pricing.InputPricePerMTokenPriority > 0 ||
243+
pricing.OutputPricePerMTokenPriority > 0 ||
244+
pricing.CacheReadPricePerMTokenPriority > 0
245+
}
246+
247+
func serviceTierCostMultiplier(serviceTier string) float64 {
248+
switch normalizeServiceTier(serviceTier) {
249+
case "priority":
250+
return 2.0
251+
case "flex":
252+
return 0.5
253+
default:
254+
return 1.0
255+
}
256+
}
59257

60-
return inputCost + outputCost
258+
func normalizeServiceTier(serviceTier string) string {
259+
return strings.ToLower(strings.TrimSpace(serviceTier))
61260
}

0 commit comments

Comments
 (0)