返回 DeepSeek-Reasonix
billing.go
根目录 / internal / config / billing.go
1 package config
2
3 import (
4 "fmt"
5 "strings"
6
7 "reasonix/internal/billing"
8 "reasonix/internal/provider"
9 )
10
11 // BillingConfig controls host-side cost display. It never changes provider
12 // list-price tables (those use ProviderEntry.BillingCurrency).
13 type BillingConfig struct {
14 // DisplayCurrency is auto|CNY|USD. Empty equals auto.
15 DisplayCurrency string `toml:"display_currency"`
16 }
17
18 // DisplayCurrencyPref returns the user preference: "" (auto), "CNY", or "USD".
19 func (c *Config) DisplayCurrencyPref() string {
20 if c == nil {
21 return ""
22 }
23 if v := normalizeDisplayCurrency(c.Billing.DisplayCurrency); v != "" {
24 return v
25 }
26 // Legacy [desktop].currency continues to work until rewritten.
27 return c.DesktopCurrency()
28 }
29
30 // ExplicitDisplayCurrency returns only a user-pinned display currency. Empty
31 // is intentional: auto is resolved by a quote/wallet presentation surface.
32 func (c *Config) ExplicitDisplayCurrency() string {
33 return c.DisplayCurrencyPref()
34 }
35
36 // ResolveDisplayCurrency is retained for callers that need a compatibility
37 // name. Auto is no longer resolved from language, host region, or browser
38 // locale; it remains empty until a surface supplies a wallet hint.
39 func (c *Config) ResolveDisplayCurrency() string {
40 return c.ExplicitDisplayCurrency()
41 }
42
43 func normalizeDisplayCurrency(currency string) string {
44 switch strings.ToUpper(strings.TrimSpace(currency)) {
45 case "", "AUTO":
46 return ""
47 case "CNY", "RMB", "CNH":
48 return "CNY"
49 case "USD":
50 return "USD"
51 default:
52 return ""
53 }
54 }
55
56 // SetDisplayCurrency pins the global display currency preference. It does not
57 // rewrite provider billing currencies or official price tables.
58 func (c *Config) SetDisplayCurrency(currency string) error {
59 if c == nil {
60 return fmt.Errorf("nil config")
61 }
62 switch strings.ToUpper(strings.TrimSpace(currency)) {
63 case "", "AUTO":
64 c.Billing.DisplayCurrency = ""
65 c.Desktop.Currency = "" // keep legacy field in sync for older readers
66 case "CNY", "RMB", "CNH":
67 c.Billing.DisplayCurrency = "CNY"
68 c.Desktop.Currency = "CNY"
69 case "USD":
70 c.Billing.DisplayCurrency = "USD"
71 c.Desktop.Currency = "USD"
72 default:
73 return fmt.Errorf("display currency %q: must be auto|CNY|USD", currency)
74 }
75 return nil
76 }
77
78 // SetDesktopCurrency is retained for call-site compatibility. It now only
79 // changes the display currency preference and never rewrites official prices.
80 func (c *Config) SetDesktopCurrency(currency string) error {
81 return c.SetDisplayCurrency(currency)
82 }
83
84 // ProviderBillingCurrency returns the frozen list-price currency for a provider.
85 func (e *ProviderEntry) ProviderBillingCurrency() string {
86 if e == nil {
87 return ""
88 }
89 if v := billing.NormalizeCurrency(e.BillingCurrency); v != "" {
90 return v
91 }
92 // Infer from configured prices when field is absent (pre-migration).
93 if e.Price != nil {
94 if v := billing.NormalizeCurrency(e.Price.Currency); v != "" {
95 return v
96 }
97 }
98 for _, p := range e.Prices {
99 if p == nil {
100 continue
101 }
102 if v := billing.NormalizeCurrency(p.Currency); v != "" {
103 return v
104 }
105 }
106 return ""
107 }
108
109 // ProviderBillingMode returns payg or subscription_equivalent.
110 func (e *ProviderEntry) ProviderBillingMode() string {
111 if e == nil {
112 return billing.BillingModePAYG
113 }
114 switch strings.ToLower(strings.TrimSpace(e.BillingMode)) {
115 case billing.BillingModeSubscriptionEquivalent, "subscription", "token_plan":
116 return billing.BillingModeSubscriptionEquivalent
117 default:
118 return billing.BillingModePAYG
119 }
120 }
121
122 // RateCardForModel builds a billing.RateCard for the active model price.
123 func (e *ProviderEntry) RateCardForModel(model string) billing.RateCard {
124 p := e.PriceForModel(model)
125 if p == nil {
126 return billing.RateCard{Currency: e.ProviderBillingCurrency()}
127 }
128 cur := billing.NormalizeCurrency(p.Currency)
129 if cur == "" {
130 cur = e.ProviderBillingCurrency()
131 }
132 return billing.RateCard{
133 CacheHit: p.CacheHit,
134 Input: p.Input,
135 Output: p.Output,
136 Currency: cur,
137 }
138 }
139
140 // PricingContextForModel returns host-trusted catalog metadata for quote
141 // construction. Dynamic schedules are enabled only for an exact official
142 // endpoint whose configured rates still match the current peak anchor.
143 func (e *ProviderEntry) PricingContextForModel(model string) billing.PricingContext {
144 if e == nil {
145 return billing.PricingContext{}
146 }
147 model = strings.TrimSpace(model)
148 kind := officialProviderKind(e)
149 protocolKind := strings.ToLower(strings.TrimSpace(e.Kind))
150 scheduledProtocol := protocolKind == "openai" || protocolKind == "responses" || protocolKind == "anthropic"
151 ctx := billing.PricingContext{
152 ProviderKind: kind,
153 ModelID: model,
154 BillingMode: e.ProviderBillingMode(),
155 }
156 card := e.RateCardForModel(model)
157 if entry, ok := billing.MatchesCatalog(kind, model, card); ok {
158 ctx.CatalogSource = entry.DocURL
159 }
160 if kind == "deepseek" && scheduledProtocol && isOfficialDeepSeekBillingEndpoint(e) && ctx.BillingMode == billing.BillingModePAYG {
161 // An anchor from an earlier price generation still proves an unedited
162 // official row, so binding the live schedule keeps such a config quoted at
163 // today's rate instead of the one it was saved with.
164 if billing.MatchesOfficialPeakAnchor(kind, model, card.Currency, ctx.BillingMode, card) {
165 ctx.ScheduleID = billing.DeepSeekScheduledIDs()[0]
166 ctx.CatalogSource = billing.DocDeepSeekPricing
167 }
168 }
169 return ctx
170 }
171
172 // isOfficialDeepSeekBillingEndpoint is deliberately protocol- and path-aware.
173 // Hostname-only matching would let a custom route on api.deepseek.com opt into
174 // vendor pricing and migrations that it may not actually use.
175 func isOfficialDeepSeekBillingEndpoint(e *ProviderEntry) bool {
176 if e == nil {
177 return false
178 }
179 switch strings.ToLower(strings.TrimSpace(e.Kind)) {
180 case "openai":
181 return isOfficialDeepSeekOpenAIEndpoint(e.BaseURL)
182 case "responses", "anthropic":
183 return IsOfficialDeepSeekWebSearchEndpoint(e)
184 default:
185 return false
186 }
187 }
188
189 // freezeProviderBillingCurrencies sets BillingCurrency from current official
190 // prices when missing. Custom prices keep their currency. Never overwrites an
191 // explicit BillingCurrency.
192 func freezeProviderBillingCurrencies(c *Config) {
193 if c == nil {
194 return
195 }
196 for i := range c.Providers {
197 p := &c.Providers[i]
198 if strings.TrimSpace(p.BillingCurrency) != "" {
199 p.BillingCurrency = billing.NormalizeCurrency(p.BillingCurrency)
200 continue
201 }
202 if cur := p.ProviderBillingCurrency(); cur != "" {
203 p.BillingCurrency = cur
204 } else if officialProviderKind(p) == "deepseek" {
205 // New default templates use USD official table historically.
206 p.BillingCurrency = "USD"
207 }
208 if strings.TrimSpace(p.BillingMode) == "" {
209 if isMiMoTokenPlanProvider(p) {
210 p.BillingMode = billing.BillingModeSubscriptionEquivalent
211 } else {
212 p.BillingMode = billing.BillingModePAYG
213 }
214 }
215 }
216 }
217
218 func isMiMoTokenPlanProvider(p *ProviderEntry) bool {
219 if p == nil {
220 return false
221 }
222 name := strings.ToLower(strings.TrimSpace(p.Name))
223 preset := strings.ToLower(strings.TrimSpace(p.PresetID))
224 return strings.Contains(name, "token-plan") || strings.Contains(preset, "token-plan")
225 }
226
227 // migrateBillingDisplayCurrency copies legacy desktop.currency into billing.
228 func migrateBillingDisplayCurrency(c *Config) {
229 if c == nil {
230 return
231 }
232 if strings.TrimSpace(c.Billing.DisplayCurrency) != "" {
233 c.Billing.DisplayCurrency = normalizeDisplayCurrency(c.Billing.DisplayCurrency)
234 return
235 }
236 if cur := c.DesktopCurrency(); cur != "" {
237 c.Billing.DisplayCurrency = cur
238 }
239 }
240
241 // DeepSeekOfficialPricingCurrency resolves which official DeepSeek table to
242 // use for *new default template fills*. It no longer follows display currency
243 // for existing providers — those use BillingCurrency. For templates without a
244 // frozen currency, prefer the first provider's billing currency, else USD.
245 func (c *Config) DeepSeekOfficialPricingCurrency() string {
246 if c != nil {
247 for i := range c.Providers {
248 p := &c.Providers[i]
249 if officialProviderKind(p) != "deepseek" {
250 continue
251 }
252 if cur := p.ProviderBillingCurrency(); cur != "" {
253 return cur
254 }
255 }
256 }
257 return "USD"
258 }
259
260 // QuoteForUsage builds a CostQuote from a provider price and usage tokens.
261 func QuoteForUsage(price *provider.Pricing, usage *provider.Usage, display string, modelRef, usageSource, billingMode, catalogSource string) billing.CostQuote {
262 if price == nil || usage == nil {
263 return billing.CostQuote{Estimated: true, CostComplete: false, DisplayComplete: false, Complete: false, DisplayStatus: billing.DisplayStatusUnavailable, IncompleteReason: "missing_price_or_usage"}
264 }
265 card := billing.RateCard{
266 CacheHit: price.CacheHit,
267 Input: price.Input,
268 Output: price.Output,
269 Currency: billing.NormalizeCurrency(price.Currency),
270 }
271 if card.Currency == "" {
272 card.Currency = "CNY"
273 }
274 return billing.BuildQuote(billing.QuoteInput{
275 Usage: billing.UsageTokens{
276 PromptTokens: usage.PromptTokens,
277 CompletionTokens: usage.CompletionTokens,
278 CacheHitTokens: usage.CacheHitTokens,
279 CacheMissTokens: usage.CacheMissTokens,
280 CacheWriteTokens: usage.CacheWriteTokens,
281 CacheWriteBilledTokens: usage.CacheWriteBilledTokens,
282 Estimated: usage.Estimated,
283 },
284 Rates: card,
285 DisplayCurrency: display,
286 BillingMode: billingMode,
287 ModelRef: modelRef,
288 UsageSource: usageSource,
289 CatalogSource: catalogSource,
290 })
291 }
292
292 lines GO