|
1 | 1 | package database |
2 | 2 |
|
| 3 | +import "strings" |
| 4 | + |
3 | 5 | // ModelPricing 模型价格配置(每百万 token 的价格,单位:美元) |
4 | 6 | 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 缓存命中输入价格 |
7 | 13 | } |
8 | 14 |
|
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 | +} |
19 | 19 |
|
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} |
22 | 22 |
|
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 | + }}, |
27 | 59 |
|
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}}, |
30 | 66 | } |
| 67 | +) |
31 | 68 |
|
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 { |
34 | 74 | return pricing |
35 | 75 | } |
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 |
42 | 78 | } |
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 |
46 | 86 | } |
47 | 87 |
|
48 | 88 | // calculateCost 计算使用费用 |
49 | 89 | // inputTokens: 输入 token 数量 |
50 | 90 | // outputTokens: 输出 token 数量 |
51 | 91 | // model: 模型名称 |
52 | 92 | // 返回:账号计费金额(美元) |
53 | | -func calculateCost(inputTokens, outputTokens int, model string) float64 { |
| 93 | +func calculateCost(inputTokens, outputTokens, cachedTokens int, model string, serviceTier string) float64 { |
54 | 94 | pricing := getModelPricing(model) |
| 95 | + inputPrice := pricing.InputPricePerMToken |
| 96 | + outputPrice := pricing.OutputPricePerMToken |
| 97 | + cacheReadPrice := pricing.CacheReadPricePerMToken |
55 | 98 |
|
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 | +} |
59 | 257 |
|
60 | | - return inputCost + outputCost |
| 258 | +func normalizeServiceTier(serviceTier string) string { |
| 259 | + return strings.ToLower(strings.TrimSpace(serviceTier)) |
61 | 260 | } |
0 commit comments