返回 DeepSeek-Reasonix
quote.go
根目录 / internal / billing / quote.go
1 package billing
2
3 import (
4 "crypto/sha256"
5 "encoding/hex"
6 "fmt"
7 "sort"
8 "strings"
9 "time"
10 )
11
12 // Billing modes.
13 const (
14 BillingModePAYG = "payg"
15 BillingModeSubscriptionEquivalent = "subscription_equivalent"
16 )
17
18 // Valuation basis values.
19 const (
20 BasisIdentity = "identity"
21 BasisOfficialTable = "official_table"
22 // BasisFX is retained for decoding pre-v6 persisted quotes only. New
23 // quotes never create FX valuations.
24 BasisFX = "fx"
25 )
26
27 // DisplayStatus describes whether a quote can satisfy its requested display.
28 // It is deliberately separate from CostComplete: a known price-book amount
29 // can exist even when no requested regional valuation is available.
30 const (
31 DisplayStatusMatched = "matched"
32 DisplayStatusFallbackOriginal = "fallback_original"
33 DisplayStatusBucketed = "bucketed"
34 DisplayStatusUnavailable = "unavailable"
35 )
36
37 // Aggregate modes describe how a session/run total was formed.
38 const (
39 AggregateModeSingleCurrency = "single_currency"
40 AggregateModeCommonValuation = "common_valuation"
41 AggregateModeCurrencyBuckets = "currency_buckets"
42 )
43
44 // DisplayRequest identifies how a caller selected the requested presentation
45 // currency. The source is runtime context only; it is never persisted as a
46 // provider price or wallet fact.
47 type DisplayRequest struct {
48 Currency string `json:"currency,omitempty"`
49 Source string `json:"source,omitempty"`
50 }
51
52 // PricingContext is trusted host metadata derived from resolved provider
53 // configuration. In particular, ScheduleID must never be inferred from a model
54 // name alone because compatible gateways may use unrelated pricing.
55 type PricingContext struct {
56 ProviderKind string
57 ModelID string
58 BillingMode string
59 ScheduleID string
60 CatalogSource string
61 }
62
63 const (
64 DisplaySourceExplicit = "explicit"
65 DisplaySourceWallet = "wallet"
66 DisplaySourceAuto = "auto"
67 )
68
69 func (r DisplayRequest) NormalizedCurrency() string {
70 return NormalizeCurrency(r.Currency)
71 }
72
73 // CostQuote is the host-side quoted cost for one model call (or an aggregate).
74 // Original is the pricing-table currency fact; valuations hold identity or
75 // official regional rate-card estimates. Selected is the display pick for the
76 // current preference.
77 type CostQuote struct {
78 Original Money `json:"original"`
79 // OriginalTotals is populated for mixed-currency aggregates. Individual
80 // quotes and single-currency totals may omit it.
81 OriginalTotals []Money `json:"originalTotals,omitempty"`
82 Valuations map[string]Valuation `json:"valuations,omitempty"`
83 Selected *Money `json:"selected,omitempty"`
84 BillingMode string `json:"billingMode,omitempty"`
85 Estimated bool `json:"estimated"`
86 // CostComplete means usage and the price-book amount are known. It does not
87 // imply that a requested display currency is available.
88 CostComplete bool `json:"costComplete"`
89 // DisplayComplete means a single selected amount satisfies the display
90 // request. Complete is retained as the old wire alias.
91 DisplayComplete bool `json:"displayComplete"`
92 Complete bool `json:"complete"`
93 DisplayStatus string `json:"displayStatus,omitempty"`
94 AggregateMode string `json:"aggregateMode,omitempty"`
95 // ModelRef / UsageSource / PricingFingerprint identify the ledger key.
96 ModelRef string `json:"modelRef,omitempty"`
97 UsageSource string `json:"usageSource,omitempty"`
98 PricingFingerprint string `json:"pricingFingerprint,omitempty"`
99 RateDate string `json:"rateDate,omitempty"` // YYYY-MM-DD of FX used, if any
100 RateBand string `json:"rateBand,omitempty"` // peak | off_peak | mixed
101 RatedAt string `json:"ratedAt,omitempty"` // RFC3339 UTC for scheduled quotes
102 IncompleteReason string `json:"incompleteReason,omitempty"`
103 LegacyEstimate bool `json:"legacyEstimate,omitempty"`
104 CatalogSource string `json:"catalogSource,omitempty"`
105 }
106
107 // Valuation is one currency view of a cost fact.
108 type Valuation struct {
109 Money Money `json:"money"`
110 Basis string `json:"basis"`
111 Source string `json:"source"`
112 AsOf string `json:"asOf"` // YYYY-MM-DD
113 Rate *RateSnapshot `json:"rateSnapshot,omitempty"`
114 Stale bool `json:"stale,omitempty"`
115 }
116
117 // RateSnapshot records the FX observation used for a valuation.
118 type RateSnapshot struct {
119 Base string `json:"base"`
120 Quote string `json:"quote"`
121 Rate float64 `json:"rate"`
122 Source string `json:"source"`
123 AsOf string `json:"asOf"`
124 Stale bool `json:"stale,omitempty"`
125 }
126
127 // RateCard is the per-1M-token price used to compute original cost. It mirrors
128 // provider.Pricing without importing that package (billing is a leaf).
129 type RateCard struct {
130 CacheHit float64 // per 1M cached prompt tokens
131 Input float64 // per 1M uncached prompt tokens
132 Output float64 // per 1M completion tokens
133 Currency string // ISO or symbol; normalized on quote
134 }
135
136 // UsageTokens is the token breakdown needed for cost. Mirrors provider.Usage
137 // fields without importing provider.
138 type UsageTokens struct {
139 PromptTokens int
140 CompletionTokens int
141 CacheHitTokens int
142 CacheMissTokens int
143 CacheWriteTokens int
144 CacheWriteBilledTokens float64
145 Estimated bool
146 }
147
148 // QuoteInput drives a single CostQuote computation.
149 type QuoteInput struct {
150 Usage UsageTokens
151 Rates RateCard
152 OccurredAt time.Time
153 DisplayCurrency string // compatibility alias for Display.Currency
154 Display DisplayRequest // runtime display request; empty means auto
155 BillingMode string
156 ModelRef string
157 UsageSource string
158 CatalogSource string
159 PricingFingerprint string
160 // ScheduleID is set only after config resolution proves that this is an
161 // official scheduled price anchor. Model names alone must not enable it.
162 ScheduleID string
163 // ProviderKind is deepseek|longcat|mimo|… for official dual-table lookup.
164 // When empty, ModelRef and Rates are used to infer a catalog match.
165 ProviderKind string
166 // ModelID is the bare model id (e.g. deepseek-v4-flash). Empty → parse ModelRef.
167 ModelID string
168 }
169
170 // OriginalCostAmount computes the fixed-point original cost from rates + tokens.
171 // Mirrors the historic provider.Pricing.Cost semantics.
172 func OriginalCostAmount(rates RateCard, u UsageTokens) Amount {
173 hit := u.CacheHitTokens
174 miss := u.CacheMissTokens
175 if hit+miss == 0 && u.PromptTokens > 0 {
176 miss = u.PromptTokens
177 } else if miss == 0 && hit > 0 && u.PromptTokens > hit {
178 miss = u.PromptTokens - hit
179 }
180 write := u.CacheWriteTokens
181 write = max(write, 0)
182 write = min(write, miss)
183 billedWrite := 0.0
184 if write > 0 {
185 billedWrite = u.CacheWriteBilledTokens
186 if billedWrite <= 0 {
187 billedWrite = float64(write)
188 }
189 }
190 inputTokenUnits := float64(miss-write) + billedWrite
191 // Combine cached input, uncached input, and output charges per million tokens.
192 total := (float64(hit)*rates.CacheHit +
193 inputTokenUnits*rates.Input +
194 float64(u.CompletionTokens)*rates.Output) / 1e6
195 return NewAmountFromFloat(total)
196 }
197
198 // PricingFingerprint hashes a rate card for ledger keys.
199 func PricingFingerprint(rates RateCard) string {
200 cur := NormalizeCurrency(rates.Currency)
201 raw := fmt.Sprintf("%s|%.12g|%.12g|%.12g", cur, rates.CacheHit, rates.Input, rates.Output)
202 sum := sha256.Sum256([]byte(raw))
203 return hex.EncodeToString(sum[:8])
204 }
205
206 // BuildQuote computes original cost and CNY/USD valuations.
207 func BuildQuote(in QuoteInput) CostQuote {
208 state := newQuoteBuildState(in)
209 for _, target := range []string{"CNY", "USD"} {
210 state.addTargetValuation(target)
211 }
212 state.selectDisplay()
213 return state.quote
214 }
215
216 type quoteBuildState struct {
217 input QuoteInput
218 currency string
219 amount Amount
220 occurred time.Time
221 official CatalogEntry
222 isOfficial bool
223 valuationFailures map[string]string
224 quote CostQuote
225 }
226
227 func newQuoteBuildState(in QuoteInput) *quoteBuildState {
228 currency := NormalizeCurrency(in.Rates.Currency)
229 if currency == "" {
230 currency = "CNY"
231 }
232 mode := strings.TrimSpace(in.BillingMode)
233 if mode == "" {
234 mode = BillingModePAYG
235 }
236 occurred := in.OccurredAt
237 if occurred.IsZero() {
238 occurred = time.Now().UTC()
239 } else {
240 occurred = occurred.UTC()
241 }
242 providerKind, modelID := resolveCatalogIdentity(in)
243 resolvedBand := ""
244 resolvedSchedule := false
245 if MatchesOfficialPeakAnchor(providerKind, modelID, in.Rates.Currency, mode, in.Rates) {
246 if resolved, ok := ResolveScheduledRate(providerKind, modelID, in.Rates.Currency, mode, in.ScheduleID, occurred); ok {
247 in.Rates = resolved.Card
248 resolvedBand = resolved.RateBand
249 resolvedSchedule = true
250 }
251 }
252 fingerprint := strings.TrimSpace(in.PricingFingerprint)
253 if fingerprint == "" || resolvedSchedule {
254 fingerprint = PricingFingerprint(in.Rates)
255 }
256 amount := OriginalCostAmount(in.Rates, in.Usage)
257 q := CostQuote{
258 Original: MoneyOf(amount, currency),
259 Valuations: map[string]Valuation{},
260 BillingMode: mode,
261 Estimated: true,
262 CostComplete: true,
263 DisplayComplete: true,
264 Complete: true,
265 DisplayStatus: DisplayStatusMatched,
266 AggregateMode: AggregateModeSingleCurrency,
267 ModelRef: strings.TrimSpace(in.ModelRef),
268 UsageSource: strings.TrimSpace(in.UsageSource),
269 PricingFingerprint: fingerprint,
270 CatalogSource: strings.TrimSpace(in.CatalogSource),
271 RateBand: resolvedBand,
272 }
273 if resolvedSchedule {
274 q.RatedAt = occurred.Format(time.RFC3339Nano)
275 }
276 q.Valuations[currency] = Valuation{
277 Money: q.Original, Basis: BasisIdentity,
278 Source: firstNonEmpty(in.CatalogSource, "rate_card"),
279 AsOf: occurred.UTC().Format("2006-01-02"),
280 }
281 state := &quoteBuildState{
282 input: in, currency: currency, amount: amount, occurred: occurred,
283 valuationFailures: map[string]string{}, quote: q,
284 }
285 if !usageHasFacts(in.Usage) {
286 state.markIncomplete("missing_price_or_usage")
287 }
288 state.matchOfficialCatalog()
289 return state
290 }
291
292 func quoteDisplayRequest(in QuoteInput) DisplayRequest {
293 request := in.Display
294 if request.Currency == "" && in.DisplayCurrency != "" {
295 request.Currency = in.DisplayCurrency
296 if request.Source == "" {
297 request.Source = DisplaySourceExplicit
298 }
299 }
300 if request.Source == "" {
301 request.Source = DisplaySourceAuto
302 }
303 return request
304 }
305
306 func usageHasFacts(u UsageTokens) bool {
307 return u.PromptTokens > 0 || u.CompletionTokens > 0 || u.CacheHitTokens > 0 ||
308 u.CacheMissTokens > 0 || u.CacheWriteTokens > 0 || u.CacheWriteBilledTokens > 0
309 }
310
311 func (s *quoteBuildState) matchOfficialCatalog() {
312 providerKind, modelID := resolveCatalogIdentity(s.input)
313 if s.input.ScheduleID != "" {
314 s.official, s.isOfficial = LookupCatalogAt(providerKind, modelID, s.currency, s.input.BillingMode,
315 s.input.ScheduleID, s.quote.RateBand, s.occurred)
316 if s.isOfficial {
317 card := RateCardFromCatalog(s.official)
318 s.isOfficial = card.CacheHit == s.input.Rates.CacheHit && card.Input == s.input.Rates.Input && card.Output == s.input.Rates.Output
319 }
320 } else {
321 s.official, s.isOfficial = MatchesCatalog(providerKind, modelID, s.input.Rates)
322 }
323 if !s.isOfficial && providerKind != "" && modelID != "" {
324 rates := s.input.Rates
325 rates.Currency = s.currency
326 if s.input.ScheduleID != "" {
327 s.official, s.isOfficial = LookupCatalogAt(providerKind, modelID, s.currency, s.input.BillingMode,
328 s.input.ScheduleID, s.quote.RateBand, s.occurred)
329 if s.isOfficial {
330 card := RateCardFromCatalog(s.official)
331 s.isOfficial = card.CacheHit == rates.CacheHit && card.Input == rates.Input && card.Output == rates.Output
332 }
333 } else {
334 s.official, s.isOfficial = MatchesCatalog(providerKind, modelID, rates)
335 }
336 }
337 if s.isOfficial && s.quote.CatalogSource == "" {
338 s.quote.CatalogSource = s.official.DocURL
339 }
340 }
341
342 func (s *quoteBuildState) addTargetValuation(target string) {
343 if target == s.currency {
344 return
345 }
346 if s.addOfficialValuation(target) {
347 return
348 }
349 // Runtime FX has intentionally been removed. A custom price that does not
350 // match the official catalog remains in its original currency.
351 s.valuationFailures[target] = "display_unavailable"
352 }
353
354 func (s *quoteBuildState) addOfficialValuation(target string) bool {
355 if !s.isOfficial {
356 return false
357 }
358 var peer CatalogEntry
359 var ok bool
360 if s.input.ScheduleID != "" {
361 peer, ok = LookupCatalogAt(s.official.Provider, s.official.Model, target, s.official.BillingMode,
362 s.input.ScheduleID, s.quote.RateBand, s.occurred)
363 } else {
364 peer, ok = LookupCatalog(s.official.Provider, s.official.Model, target, s.official.BillingMode)
365 }
366 if !ok {
367 return false
368 }
369 s.quote.Valuations[target] = Valuation{
370 Money: MoneyOf(OriginalCostAmount(RateCardFromCatalog(peer), s.input.Usage), target),
371 Basis: BasisOfficialTable, Source: peer.DocURL,
372 AsOf: s.occurred.UTC().Format("2006-01-02"),
373 }
374 return true
375 }
376
377 func (s *quoteBuildState) selectDisplay() {
378 if !s.quote.CostComplete {
379 s.quote.Selected = nil
380 s.quote.DisplayComplete = false
381 s.quote.Complete = false
382 s.quote.DisplayStatus = DisplayStatusUnavailable
383 return
384 }
385 display := quoteDisplayRequest(s.input).NormalizedCurrency()
386 if display == "" {
387 selected := s.quote.Original
388 s.quote.Selected = &selected
389 return
390 }
391 if valuation, ok := s.quote.Valuations[display]; ok {
392 selected := valuation.Money
393 s.quote.Selected = &selected
394 return
395 }
396 // The original price-book amount is still a valid cost fact. Keep it as a
397 // visible fallback and distinguish the display mismatch from no pricing.
398 selected := s.quote.Original
399 s.quote.Selected = &selected
400 s.quote.DisplayComplete = false
401 s.quote.Complete = false
402 s.quote.DisplayStatus = DisplayStatusFallbackOriginal
403 s.quote.IncompleteReason = firstNonEmpty(s.valuationFailures[display], "display_unavailable")
404 }
405
406 func (s *quoteBuildState) markIncomplete(reason string) {
407 s.quote.CostComplete = false
408 s.quote.DisplayComplete = false
409 s.quote.Complete = false
410 s.quote.DisplayStatus = DisplayStatusUnavailable
411 if s.quote.IncompleteReason == "" {
412 s.quote.IncompleteReason = reason
413 }
414 }
415
416 // SelectForDisplay returns the money for a display currency preference without
417 // recomputing rates. Empty display returns original.
418 func (q CostQuote) SelectForDisplay(display string) (Money, bool) {
419 display = NormalizeCurrency(display)
420 if display == "" {
421 return q.Original, true
422 }
423 if v, ok := q.Valuations[display]; ok {
424 return v.Money, true
425 }
426 if NormalizeCurrency(q.Original.Currency) == display {
427 return q.Original, true
428 }
429 return Money{}, false
430 }
431
432 // WithSelected returns a copy with Selected set for display.
433 func (q CostQuote) WithSelected(display string) CostQuote {
434 out := q
435 if m, ok := q.SelectForDisplay(display); ok {
436 out.Selected = &m
437 if quoteHasCompleteCostFact(q) {
438 out.Complete = true
439 out.IncompleteReason = ""
440 }
441 } else {
442 out.Selected = nil
443 out.Complete = false
444 if out.IncompleteReason == "" {
445 out.IncompleteReason = "display_unavailable"
446 }
447 }
448 return out
449 }
450
451 // LegacyCostFloat returns the selected (or original) amount as float64 for
452 // compatibility aliases cost / costUsd / total_cost.
453 func (q CostQuote) LegacyCostFloat() float64 {
454 if q.Selected != nil {
455 return q.Selected.Float64()
456 }
457 return q.Original.Float64()
458 }
459
460 // LegacyCurrencySymbol returns a display symbol for the selected/original currency.
461 func (q CostQuote) LegacyCurrencySymbol() string {
462 if q.Selected != nil && q.Selected.Currency != "" {
463 return CurrencySymbol(q.Selected.Currency)
464 }
465 return CurrencySymbol(q.Original.Currency)
466 }
467
468 // LegacyCurrencyCode returns the ISO code for selected/original.
469 func (q CostQuote) LegacyCurrencyCode() string {
470 if q.Selected != nil && q.Selected.Currency != "" {
471 return NormalizeCurrency(q.Selected.Currency)
472 }
473 return NormalizeCurrency(q.Original.Currency)
474 }
475
476 // AggregateQuotes combines occurrence-time quotes into a session/run total.
477 // Every entry must have the requested valuation; partial totals stay unavailable.
478 func AggregateQuotes(quotes []CostQuote, display string) CostQuote {
479 if len(quotes) == 0 {
480 return emptyAggregate(NormalizeCurrency(display))
481 }
482 accumulator := newQuoteAccumulator(display)
483 for _, quote := range quotes {
484 accumulator.add(quote)
485 }
486 return accumulator.finish()
487 }
488
489 type quoteAccumulator struct {
490 out CostQuote
491 display string
492 totals map[string]Amount
493 originalCurrency string
494 originalTotal Amount
495 originalTotals map[string]Amount
496 originalComplete bool
497 costFactsComplete bool
498 displayComplete bool
499 modes map[string]struct{}
500 rateBands map[string]struct{}
501 unknownRateBand bool
502 }
503
504 func emptyAggregate(display string) CostQuote {
505 out := CostQuote{
506 Original: MoneyOf(Zero, display), Valuations: map[string]Valuation{}, Estimated: true,
507 CostComplete: false, DisplayComplete: false, Complete: false,
508 DisplayStatus: DisplayStatusUnavailable, AggregateMode: AggregateModeSingleCurrency,
509 IncompleteReason: "no_usage",
510 }
511 return out
512 }
513
514 func newQuoteAccumulator(display string) *quoteAccumulator {
515 return &quoteAccumulator{
516 out: CostQuote{Valuations: map[string]Valuation{}, Estimated: true},
517 display: NormalizeCurrency(display), totals: map[string]Amount{}, originalTotals: map[string]Amount{},
518 originalComplete: true, costFactsComplete: true, displayComplete: true,
519 modes: map[string]struct{}{}, rateBands: map[string]struct{}{},
520 }
521 }
522
523 func (a *quoteAccumulator) add(quote CostQuote) {
524 quote = NormalizeQuote(quote)
525 if !quoteHasCompleteCostFact(quote) {
526 a.costFactsComplete = false
527 if a.out.IncompleteReason == "" {
528 a.out.IncompleteReason = quote.IncompleteReason
529 }
530 }
531 a.out.LegacyEstimate = a.out.LegacyEstimate || quote.LegacyEstimate
532 if quote.BillingMode != "" {
533 a.modes[quote.BillingMode] = struct{}{}
534 }
535 switch quote.RateBand {
536 case RateBandPeak, RateBandOffPeak:
537 a.rateBands[quote.RateBand] = struct{}{}
538 default:
539 a.unknownRateBand = true
540 }
541 if quote.RateDate > a.out.RateDate {
542 a.out.RateDate = quote.RateDate
543 }
544 originalCurrency := NormalizeCurrency(quote.Original.Currency)
545 if len(quote.OriginalTotals) > 0 {
546 for _, original := range quote.OriginalTotals {
547 a.addOriginal(original, NormalizeCurrency(original.Currency))
548 }
549 } else {
550 a.addOriginal(quote.Original, originalCurrency)
551 }
552 if a.display != "" {
553 _, found := quote.Valuations[a.display]
554 a.displayComplete = a.displayComplete && (found || originalCurrency == a.display)
555 }
556 originalIncluded := false
557 for code, valuation := range quote.Valuations {
558 code = NormalizeCurrency(code)
559 if code == originalCurrency {
560 originalIncluded = true
561 }
562 a.addValuation(code, valuation)
563 }
564 if originalCurrency != "" && !originalIncluded {
565 a.addValuation(originalCurrency, Valuation{
566 Money: quote.Original, Basis: BasisIdentity, Source: "aggregate",
567 AsOf: time.Now().UTC().Format("2006-01-02"),
568 })
569 }
570 }
571
572 func (a *quoteAccumulator) addOriginal(original Money, currency string) {
573 if currency != "" {
574 a.originalTotals[currency] = a.originalTotals[currency].Add(original.AmountValue())
575 }
576 if a.originalCurrency == "" {
577 a.originalCurrency = currency
578 a.originalTotal = original.AmountValue()
579 return
580 }
581 if currency != a.originalCurrency {
582 a.originalComplete = false
583 return
584 }
585 a.originalTotal = a.originalTotal.Add(original.AmountValue())
586 }
587
588 func (a *quoteAccumulator) addValuation(code string, valuation Valuation) {
589 if code == "" {
590 return
591 }
592 a.totals[code] = a.totals[code].Add(valuation.Money.AmountValue())
593 current, found := a.out.Valuations[code]
594 if !found {
595 current = valuation
596 }
597 current.Money = MoneyOf(a.totals[code], code)
598 current.Stale = current.Stale || valuation.Stale
599 a.out.Valuations[code] = current
600 }
601
602 func (a *quoteAccumulator) finish() CostQuote {
603 a.out.CostComplete = a.costFactsComplete
604 if a.originalComplete && a.originalCurrency != "" {
605 a.out.Original = MoneyOf(a.originalTotal, a.originalCurrency)
606 a.syncOriginalValuation()
607 } else {
608 a.out.Original = Money{Amount: "0"}
609 }
610 if len(a.originalTotals) > 1 {
611 codes := make([]string, 0, len(a.originalTotals))
612 for code := range a.originalTotals {
613 codes = append(codes, code)
614 }
615 sort.Strings(codes)
616 for _, code := range codes {
617 a.out.OriginalTotals = append(a.out.OriginalTotals, MoneyOf(a.originalTotals[code], code))
618 }
619 }
620 if len(a.modes) == 1 {
621 for mode := range a.modes {
622 a.out.BillingMode = mode
623 }
624 }
625 if !a.unknownRateBand {
626 switch len(a.rateBands) {
627 case 1:
628 for band := range a.rateBands {
629 a.out.RateBand = band
630 }
631 case 2:
632 a.out.RateBand = RateBandMixed
633 }
634 }
635 if a.display == "" && a.originalComplete && a.costFactsComplete {
636 selected := a.out.Original
637 a.out.Selected = &selected
638 a.out.DisplayComplete = true
639 a.out.Complete = true
640 a.out.DisplayStatus = DisplayStatusMatched
641 a.out.AggregateMode = AggregateModeSingleCurrency
642 return a.out
643 }
644 valuation, found := a.out.Valuations[a.display]
645 if found && a.displayComplete && a.costFactsComplete {
646 selected := valuation.Money
647 a.out.Selected = &selected
648 a.out.DisplayComplete = true
649 a.out.Complete = true
650 a.out.DisplayStatus = DisplayStatusMatched
651 if len(a.originalTotals) > 1 {
652 a.out.AggregateMode = AggregateModeCommonValuation
653 } else {
654 a.out.AggregateMode = AggregateModeSingleCurrency
655 }
656 return a.out
657 }
658 a.out.DisplayComplete = false
659 a.out.Complete = false
660 if !a.costFactsComplete {
661 a.out.DisplayStatus = DisplayStatusUnavailable
662 a.out.IncompleteReason = firstNonEmpty(a.out.IncompleteReason, "incomplete_cost_fact")
663 } else if a.originalComplete && a.originalCurrency != "" {
664 selected := a.out.Original
665 a.out.Selected = &selected
666 a.out.DisplayStatus = DisplayStatusFallbackOriginal
667 a.out.AggregateMode = AggregateModeSingleCurrency
668 a.out.IncompleteReason = firstNonEmpty(a.out.IncompleteReason, "display_unavailable")
669 } else {
670 a.out.DisplayStatus = DisplayStatusBucketed
671 a.out.AggregateMode = AggregateModeCurrencyBuckets
672 a.out.IncompleteReason = firstNonEmpty(a.out.IncompleteReason, "currency_buckets")
673 }
674 return a.out
675 }
676
677 // NormalizeQuote fills fields introduced after the first CostQuote wire shape.
678 // It is used at every persistence and transport boundary so old telemetry and
679 // old eventwire payloads remain safe without inventing a new amount.
680 func NormalizeQuote(q CostQuote) CostQuote {
681 if !q.CostComplete && !q.DisplayComplete && q.Complete {
682 q.CostComplete = true
683 q.DisplayComplete = true
684 }
685 if q.DisplayStatus != "" && q.DisplayStatus != DisplayStatusMatched && q.DisplayStatus != DisplayStatusFallbackOriginal && q.DisplayStatus != DisplayStatusBucketed && q.DisplayStatus != DisplayStatusUnavailable {
686 q.DisplayStatus = ""
687 }
688 if q.AggregateMode != "" && q.AggregateMode != AggregateModeSingleCurrency && q.AggregateMode != AggregateModeCommonValuation && q.AggregateMode != AggregateModeCurrencyBuckets {
689 q.AggregateMode = ""
690 }
691 if q.RateBand != "" && q.RateBand != RateBandPeak && q.RateBand != RateBandOffPeak && q.RateBand != RateBandMixed {
692 q.RateBand = ""
693 }
694 if q.DisplayStatus == "" {
695 switch {
696 case q.Complete:
697 q.DisplayStatus = DisplayStatusMatched
698 case q.Original.Currency != "" && q.Original.Amount != "" && q.IncompleteReason != "no_price":
699 q.DisplayStatus = DisplayStatusFallbackOriginal
700 default:
701 q.DisplayStatus = DisplayStatusUnavailable
702 }
703 }
704 if q.AggregateMode == "" {
705 if len(q.OriginalTotals) > 1 {
706 q.AggregateMode = AggregateModeCurrencyBuckets
707 } else if q.Selected != nil {
708 q.AggregateMode = AggregateModeSingleCurrency
709 }
710 }
711 q.Complete = q.DisplayComplete
712 return q
713 }
714
715 // quoteHasCompleteCostFact separates a known original-currency charge from a
716 // display-only valuation failure must not poison an exact original total,
717 // while unpriced and unrecoverable legacy records stay incomplete in every
718 // display currency.
719 func quoteHasCompleteCostFact(q CostQuote) bool {
720 if q.CostComplete {
721 return true
722 }
723 switch q.IncompleteReason {
724 case "no_price", "missing_price_or_usage", "legacy_unrecoverable",
725 "legacy_wiped_or_zero", "legacy_invalid_amount", "mixed_original_currencies",
726 "incomplete_cost_fact":
727 return false
728 }
729 if q.Complete {
730 return true
731 }
732 currency := NormalizeCurrency(q.Original.Currency)
733 if currency == "" {
734 return false
735 }
736 valuation, ok := q.Valuations[currency]
737 return ok && SameCurrency(valuation.Money.Currency, currency)
738 }
739
740 func (a *quoteAccumulator) syncOriginalValuation() {
741 valuation, found := a.out.Valuations[a.originalCurrency]
742 if !found {
743 valuation = Valuation{
744 Basis: BasisIdentity, Source: "aggregate",
745 AsOf: time.Now().UTC().Format("2006-01-02"),
746 }
747 }
748 valuation.Money = a.out.Original
749 a.out.Valuations[a.originalCurrency] = valuation
750 }
751
752 func firstNonEmpty(values ...string) string {
753 for _, v := range values {
754 if strings.TrimSpace(v) != "" {
755 return strings.TrimSpace(v)
756 }
757 }
758 return ""
759 }
760
761 func resolveCatalogIdentity(in QuoteInput) (provider, model string) {
762 provider = strings.ToLower(strings.TrimSpace(in.ProviderKind))
763 model = strings.TrimSpace(in.ModelID)
764 if model == "" {
765 ref := strings.TrimSpace(in.ModelRef)
766 if i := strings.LastIndex(ref, "/"); i >= 0 && i+1 < len(ref) {
767 model = ref[i+1:]
768 if provider == "" {
769 provider = strings.ToLower(ref[:i])
770 }
771 } else {
772 model = ref
773 }
774 }
775 // Normalize common provider name prefixes.
776 switch {
777 case strings.Contains(provider, "deepseek"):
778 provider = "deepseek"
779 case strings.Contains(provider, "longcat"):
780 provider = "longcat"
781 case strings.Contains(provider, "mimo"):
782 provider = "mimo"
783 }
784 if provider == "" {
785 // Infer from model id family.
786 switch {
787 case strings.HasPrefix(model, "deepseek"):
788 provider = "deepseek"
789 case strings.HasPrefix(model, "LongCat") || strings.HasPrefix(model, "longcat"):
790 provider = "longcat"
791 case strings.HasPrefix(model, "mimo"):
792 provider = "mimo"
793 }
794 }
795 return provider, model
796 }
797
797 lines GO