返回 DeepSeek-Reasonix
ledger.go
根目录 / internal / billing / ledger.go
1 package billing
2
3 import (
4 "maps"
5 "sort"
6 "strings"
7 "time"
8 )
9
10 // LedgerEntry is one occurrence-time cost fact. Ledger keys include model,
11 // usage source, pricing fingerprint, and legacy rate date so model switches and
12 // sub-agents never collapse into a single scalar.
13 type LedgerEntry struct {
14 Key string `json:"key"`
15 ModelRef string `json:"modelRef"`
16 UsageSource string `json:"usageSource"`
17 PricingFingerprint string `json:"pricingFingerprint"`
18 RateDate string `json:"rateDate,omitempty"`
19 OccurredAt time.Time `json:"occurredAt"`
20 Quote CostQuote `json:"quote"`
21 // Token totals for this bucket (summed).
22 PromptTokens int `json:"promptTokens"`
23 CompletionTokens int `json:"completionTokens"`
24 TotalTokens int `json:"totalTokens"`
25 CacheHitTokens int `json:"cacheHitTokens"`
26 CacheMissTokens int `json:"cacheMissTokens"`
27 RequestCount int `json:"requestCount"`
28 }
29
30 // LedgerKey builds the stable aggregation key.
31 func LedgerKey(modelRef, usageSource, pricingFingerprint, rateDate string) string {
32 return strings.Join([]string{
33 strings.TrimSpace(modelRef),
34 strings.TrimSpace(usageSource),
35 strings.TrimSpace(pricingFingerprint),
36 strings.TrimSpace(rateDate),
37 }, "|")
38 }
39
40 // Ledger accumulates occurrence-time quotes. Switching display currency only
41 // re-selects valuations already stored; it does not reprice or clear history.
42 type Ledger struct {
43 Version int `json:"version"`
44 Entries map[string]LedgerEntry `json:"entries"`
45 }
46
47 // LedgerVersion is the current persisted ledger schema.
48 const LedgerVersion = 1
49
50 // NewLedger returns an empty ledger.
51 func NewLedger() *Ledger {
52 return &Ledger{Version: LedgerVersion, Entries: map[string]LedgerEntry{}}
53 }
54
55 // Add merges a quote into the ledger under its natural key.
56 func (l *Ledger) Add(q CostQuote, tokens UsageTokens, occurred time.Time) {
57 if l == nil {
58 return
59 }
60 if l.Entries == nil {
61 l.Entries = map[string]LedgerEntry{}
62 }
63 if l.Version == 0 {
64 l.Version = LedgerVersion
65 }
66 if occurred.IsZero() {
67 occurred = time.Now().UTC()
68 }
69 q = NormalizeQuote(q)
70 source := q.UsageSource
71 if source == "" {
72 source = "executor"
73 }
74 key := LedgerKey(q.ModelRef, source, q.PricingFingerprint, q.RateDate)
75 ent, ok := l.Entries[key]
76 if !ok {
77 ent = LedgerEntry{
78 Key: key,
79 ModelRef: q.ModelRef,
80 UsageSource: source,
81 PricingFingerprint: q.PricingFingerprint,
82 RateDate: q.RateDate,
83 OccurredAt: occurred,
84 Quote: q,
85 }
86 // Fresh quote valuations are kept; we re-aggregate Original via sums.
87 ent.Quote.Valuations = cloneValuations(q.Valuations)
88 } else {
89 // A bucket with more than one occurrence no longer has a single rating
90 // instant even though its fingerprint keeps the rate band homogeneous.
91 ent.Quote.RatedAt = ""
92 // Sum original when same currency; otherwise retain deterministic
93 // per-currency buckets. Once a bucketed entry exists, keep adding into
94 // those buckets so later same-currency calls are not lost.
95 if len(ent.Quote.OriginalTotals) > 0 {
96 ent.Quote.OriginalTotals = mergeOriginalTotals(ent.Quote.OriginalTotals, q)
97 ent.Quote.CostComplete = true
98 ent.Quote.DisplayComplete = false
99 ent.Quote.Complete = false
100 ent.Quote.DisplayStatus = DisplayStatusBucketed
101 ent.Quote.AggregateMode = AggregateModeCurrencyBuckets
102 ent.Quote.IncompleteReason = "mixed_original_currencies"
103 } else {
104 sum, err := AddMoney(ent.Quote.Original, q.Original)
105 if err != nil {
106 ent.Quote.OriginalTotals = mergeOriginalTotals([]Money{ent.Quote.Original}, q)
107 ent.Quote.CostComplete = true
108 ent.Quote.DisplayComplete = false
109 ent.Quote.Complete = false
110 ent.Quote.DisplayStatus = DisplayStatusBucketed
111 ent.Quote.AggregateMode = AggregateModeCurrencyBuckets
112 ent.Quote.IncompleteReason = "mixed_original_currencies"
113 } else {
114 ent.Quote.Original = sum
115 }
116 }
117 for code, v := range q.Valuations {
118 code = NormalizeCurrency(code)
119 if prev, ok := ent.Quote.Valuations[code]; ok {
120 added, err := AddMoney(prev.Money, v.Money)
121 if err == nil {
122 prev.Money = added
123 if v.Stale {
124 prev.Stale = true
125 }
126 ent.Quote.Valuations[code] = prev
127 }
128 } else {
129 if ent.Quote.Valuations == nil {
130 ent.Quote.Valuations = map[string]Valuation{}
131 }
132 ent.Quote.Valuations[code] = v
133 }
134 }
135 if q.Estimated {
136 ent.Quote.Estimated = true
137 }
138 if !q.Complete {
139 ent.Quote.DisplayComplete = false
140 ent.Quote.Complete = false
141 if ent.Quote.IncompleteReason == "" {
142 ent.Quote.IncompleteReason = q.IncompleteReason
143 }
144 }
145 ent.Quote.CostComplete = ent.Quote.CostComplete && q.CostComplete
146 if ent.Quote.DisplayStatus != DisplayStatusBucketed {
147 ent.Quote.DisplayStatus = q.DisplayStatus
148 }
149 }
150 ent.PromptTokens += tokens.PromptTokens
151 ent.CompletionTokens += tokens.CompletionTokens
152 ent.TotalTokens += tokens.PromptTokens + tokens.CompletionTokens
153 if tokens.CacheHitTokens+tokens.CacheMissTokens > 0 {
154 ent.CacheHitTokens += tokens.CacheHitTokens
155 ent.CacheMissTokens += tokens.CacheMissTokens
156 } else {
157 ent.CacheMissTokens += tokens.PromptTokens
158 }
159 ent.RequestCount++
160 if occurred.After(ent.OccurredAt) {
161 ent.OccurredAt = occurred
162 }
163 l.Entries[key] = ent
164 }
165
166 func mergeOriginalTotals(existing []Money, q CostQuote) []Money {
167 amounts := map[string]Amount{}
168 add := func(m Money) {
169 currency := NormalizeCurrency(m.Currency)
170 if currency == "" {
171 return
172 }
173 amounts[currency] = amounts[currency].Add(m.AmountValue())
174 }
175 for _, m := range existing {
176 add(m)
177 }
178 if len(q.OriginalTotals) > 0 {
179 for _, m := range q.OriginalTotals {
180 add(m)
181 }
182 } else {
183 add(q.Original)
184 }
185 codes := make([]string, 0, len(amounts))
186 for code := range amounts {
187 codes = append(codes, code)
188 }
189 sort.Strings(codes)
190 out := make([]Money, 0, len(codes))
191 for _, code := range codes {
192 out = append(out, MoneyOf(amounts[code], code))
193 }
194 return out
195 }
196
197 func cloneValuations(in map[string]Valuation) map[string]Valuation {
198 if len(in) == 0 {
199 return map[string]Valuation{}
200 }
201 out := make(map[string]Valuation, len(in))
202 maps.Copy(out, in)
203 return out
204 }
205
206 // Total returns the aggregate CostQuote for a display currency.
207 func (l *Ledger) Total(display string) CostQuote {
208 if l == nil || len(l.Entries) == 0 {
209 return AggregateQuotes(nil, display)
210 }
211 quotes := make([]CostQuote, 0, len(l.Entries))
212 for _, ent := range l.Entries {
213 q := ent.Quote
214 // Refresh valuation moneys already summed in entries.
215 quotes = append(quotes, q)
216 }
217 // Stable order for determinism.
218 sort.Slice(quotes, func(i, j int) bool {
219 return quotes[i].ModelRef+quotes[i].UsageSource < quotes[j].ModelRef+quotes[j].UsageSource
220 })
221 return AggregateQuotes(quotes, display)
222 }
223
224 // SelectDisplay rebinds Selected on every entry and the total without
225 // recomputing occurrence-time pricing facts.
226 func (l *Ledger) SelectDisplay(display string) CostQuote {
227 return l.Total(display)
228 }
229
230 // EntriesBySource groups ledger entries by usage source for status panels.
231 func (l *Ledger) EntriesBySource() map[string][]LedgerEntry {
232 out := map[string][]LedgerEntry{}
233 if l == nil {
234 return out
235 }
236 for _, ent := range l.Entries {
237 out[ent.UsageSource] = append(out[ent.UsageSource], ent)
238 }
239 return out
240 }
241
241 lines GO