返回 DeepSeek-Reasonix
query.go
根目录 / internal / stats / query.go
1 package stats
2
3 import (
4 "context"
5 "maps"
6 "sort"
7 "strings"
8 "time"
9
10 "reasonix/internal/usagecatalog"
11 )
12
13 // DailyTokens is one day's token usage and turn count in a trend series.
14 type DailyTokens struct {
15 Day string `json:"day"` // "2026-08-02"
16 Total int `json:"total"`
17 ByModel map[string]int64 `json:"byModel"` // model ref -> tokens
18 ByProvider map[string]int64 `json:"byProvider"` // provider name -> tokens
19 Requests int `json:"requests"` // provider API requests
20 Turns int `json:"turns"` // completed turns
21 CacheHit int64 `json:"cacheHit"` // cached input tokens that day
22 CacheMiss int64 `json:"cacheMiss"` // uncached input tokens that day
23 }
24
25 // ModelUsage is one model's aggregate within the range.
26 type ModelUsage struct {
27 Model string `json:"model"`
28 Provider string `json:"provider"`
29 Tokens int64 `json:"tokens"`
30 Percent float64 `json:"percent"` // 0..100
31 }
32
33 // ProviderUsage is one provider's aggregate within the range (each provider
34 // may serve several models).
35 type ProviderUsage struct {
36 Provider string `json:"provider"`
37 Tokens int64 `json:"tokens"`
38 Percent float64 `json:"percent"`
39 }
40
41 // RangeStats is the full aggregate the settings panel renders for one time
42 // range and source filter.
43 type RangeStats struct {
44 From string `json:"from"` // inclusive
45 To string `json:"to"` // inclusive
46 // Totals
47 Tokens int64 `json:"tokens"`
48 Requests int `json:"requests"` // provider API requests
49 Turns int `json:"turns"` // completed turns
50 CacheHit int64 `json:"cache_hit"`
51 CacheMiss int64 `json:"cache_miss"`
52 // Derived
53 ActiveDays int `json:"active_days"`
54 TopModel string `json:"top_model"`
55 TopProvider string `json:"top_provider"`
56 // Series
57 Daily []DailyTokens `json:"daily"`
58 Models []ModelUsage `json:"models"`
59 Providers []ProviderUsage `json:"providers"`
60 }
61
62 // SourceFilter selects which source labels to aggregate; "" or "all" includes
63 // every source.
64 type SourceFilter struct {
65 Source string
66 From time.Time
67 To time.Time
68 }
69
70 // Query aggregates the daily stats files intersecting [from, to]. Missing days
71 // yield zero entries. When SourceFilter.Source is set, only records whose
72 // Source matches are counted.
73 func (w *Writer) Query(f SourceFilter) (RangeStats, error) {
74 var manager *usageManager
75 if w != nil {
76 manager = w.usage
77 if manager == nil {
78 manager = existingUsageManager(w.dir)
79 }
80 }
81 if manager != nil {
82 if catalog := manager.catalog.Load(); catalog != nil {
83 days := daysInRange(f.From, f.To)
84 if catalog.Ready(context.Background(), w.dir, days) {
85 rows, err := catalog.Query(context.Background(), f.From.Format(dayLayout), f.To.Format(dayLayout), f.Source)
86 if err == nil {
87 return rangeStatsFromRollups(f, days, rows), nil
88 }
89 }
90 catalog.RequestReconcileDir(w.dir)
91 catalog.NoteFallback()
92 }
93 }
94 return w.queryJSONL(f)
95 }
96
97 func (w *Writer) queryJSONL(f SourceFilter) (RangeStats, error) {
98 out := RangeStats{
99 From: f.From.Format(dayLayout),
100 To: f.To.Format(dayLayout),
101 Daily: []DailyTokens{},
102 Models: []ModelUsage{},
103 Providers: []ProviderUsage{},
104 }
105 if w == nil || w.dir == "" {
106 return out, nil
107 }
108 days := daysInRange(f.From, f.To)
109 recordsByDay, err := readDailyRange(w.dir, days)
110 if err != nil {
111 return out, err
112 }
113 modelTotals := map[string]int64{}
114 providerTotals := map[string]int64{}
115 active := map[string]bool{} // day -> active
116 for _, day := range days {
117 recs := recordsByDay[day]
118 dayTotals := map[string]int64{}
119 dayTurns := 0
120 dayRequests := 0
121 dayCacheHit := int64(0)
122 dayCacheMiss := int64(0)
123 dayActive := false
124 for _, rec := range recs {
125 if !matchesSource(rec.Source, f.Source) {
126 continue
127 }
128 if rec.Turn {
129 dayTurns++
130 continue
131 }
132 t := int64(rec.Total)
133 // Tokens keeps the provider's TotalTokens value as-is (input +
134 // output, provider-specific); the cache hit-rate is derived only
135 // from the input side (CacheHit+CacheMiss), so the two denominators
136 // never mix even when a provider reports totals that omit cache
137 // tokens.
138 out.Tokens += t
139 out.CacheHit += int64(rec.CacheHit)
140 out.CacheMiss += int64(rec.CacheMiss)
141 dayCacheHit += int64(rec.CacheHit)
142 dayCacheMiss += int64(rec.CacheMiss)
143 requests := rec.Requests
144 if rec.Total > 0 && requests <= 0 {
145 // Rows written before request accounting existed represented one
146 // successful provider call. Keep that legacy default while allowing
147 // new request-only rows to carry tokens=0 and requests>0.
148 requests = 1
149 }
150 if requests > 0 {
151 out.Requests += requests
152 dayRequests += requests
153 }
154 if rec.Total > 0 {
155 model := rec.ModelRef
156 if model == "" {
157 model = "(unknown)"
158 }
159 modelTotals[model] += t
160 providerTotals[providerOf(model)] += t
161 dayTotals[model] += t
162 }
163 dayActive = dayActive || rec.Total > 0 || requests > 0
164 }
165 if dayActive {
166 active[day] = true
167 }
168 // Turns are tallied here for every day of the range (turn markers are
169 // matched before the token branch above), so turn-only days count too.
170 out.Turns += dayTurns
171 // Emit every day of the range so the trend chart shows the full
172 // timeline; inactive days carry zero totals. The frontend trims the
173 // left side of the chart on narrow containers instead of hiding days.
174 byModel := make(map[string]int64, len(dayTotals))
175 maps.Copy(byModel, dayTotals)
176 byProvider := map[string]int64{}
177 for m, v := range dayTotals {
178 byProvider[providerOf(m)] += v
179 }
180 out.Daily = append(out.Daily, DailyTokens{
181 Day: day,
182 Total: int(sum(dayTotals)),
183 ByModel: byModel,
184 ByProvider: byProvider,
185 Requests: dayRequests,
186 Turns: dayTurns,
187 CacheHit: dayCacheHit,
188 CacheMiss: dayCacheMiss,
189 })
190 }
191 out.ActiveDays = len(active)
192 out.Models = modelsSorted(modelTotals)
193 out.Providers = providersSorted(providerTotals)
194 if len(out.Models) > 0 {
195 out.TopModel = out.Models[0].Model
196 }
197 if len(out.Providers) > 0 {
198 out.TopProvider = out.Providers[0].Provider
199 }
200 if out.Tokens > 0 {
201 for i := range out.Models {
202 out.Models[i].Percent = float64(out.Models[i].Tokens) / float64(out.Tokens) * 100
203 }
204 for i := range out.Providers {
205 out.Providers[i].Percent = float64(out.Providers[i].Tokens) / float64(out.Tokens) * 100
206 }
207 }
208 sort.SliceStable(out.Daily, func(i, j int) bool { return out.Daily[i].Day < out.Daily[j].Day })
209 return out, nil
210 }
211
212 func rangeStatsFromRollups(f SourceFilter, days []string, rows []usagecatalog.Rollup) RangeStats {
213 out := RangeStats{From: f.From.Format(dayLayout), To: f.To.Format(dayLayout), Daily: []DailyTokens{}, Models: []ModelUsage{}, Providers: []ProviderUsage{}}
214 byDay := map[string][]usagecatalog.Rollup{}
215 for _, row := range rows {
216 byDay[row.Day] = append(byDay[row.Day], row)
217 }
218 modelTotals := map[string]int64{}
219 providerTotals := map[string]int64{}
220 active := map[string]bool{}
221 for _, day := range days {
222 dayModels := map[string]int64{}
223 dayProviders := map[string]int64{}
224 dayRequests, dayTurns := int64(0), int64(0)
225 dayCacheHit, dayCacheMiss := int64(0), int64(0)
226 for _, row := range byDay[day] {
227 out.Tokens += row.Total
228 out.Requests += int(row.Requests)
229 out.Turns += int(row.Turns)
230 out.CacheHit += row.CacheHit
231 out.CacheMiss += row.CacheMiss
232 dayRequests += row.Requests
233 dayTurns += row.Turns
234 dayCacheHit += row.CacheHit
235 dayCacheMiss += row.CacheMiss
236 if row.Total > 0 {
237 model := row.ModelRef
238 if model == "" {
239 model = "(unknown)"
240 }
241 provider := row.Provider
242 if provider == "" {
243 provider = providerOf(model)
244 }
245 modelTotals[model] += row.Total
246 providerTotals[provider] += row.Total
247 dayModels[model] += row.Total
248 dayProviders[provider] += row.Total
249 }
250 if row.Total > 0 || row.Requests > 0 {
251 active[day] = true
252 }
253 }
254 out.Daily = append(out.Daily, DailyTokens{Day: day, Total: int(sum(dayModels)), ByModel: dayModels, ByProvider: dayProviders,
255 Requests: int(dayRequests), Turns: int(dayTurns), CacheHit: dayCacheHit, CacheMiss: dayCacheMiss})
256 }
257 out.ActiveDays = len(active)
258 out.Models = modelsSorted(modelTotals)
259 out.Providers = providersSorted(providerTotals)
260 if len(out.Models) > 0 {
261 out.TopModel = out.Models[0].Model
262 }
263 if len(out.Providers) > 0 {
264 out.TopProvider = out.Providers[0].Provider
265 }
266 if out.Tokens > 0 {
267 for i := range out.Models {
268 out.Models[i].Percent = float64(out.Models[i].Tokens) / float64(out.Tokens) * 100
269 }
270 for i := range out.Providers {
271 out.Providers[i].Percent = float64(out.Providers[i].Tokens) / float64(out.Tokens) * 100
272 }
273 }
274 return out
275 }
276
277 // matchesSource reports whether a record's source label passes the filter.
278 // An empty filter or "all" matches every source.
279 func matchesSource(recSource, filter string) bool {
280 if filter == "" || filter == "all" {
281 return true
282 }
283 return recSource == filter
284 }
285
286 func providerOf(modelRef string) string {
287 // model refs are "provider/model"; a bare model name (legacy configs) has
288 // no slash and is attributed to provider "default".
289 if i := strings.IndexByte(modelRef, '/'); i > 0 {
290 return modelRef[:i]
291 }
292 return "default"
293 }
294
295 func sum(m map[string]int64) int64 {
296 var s int64
297 for _, v := range m {
298 s += v
299 }
300 return s
301 }
302
303 func modelsSorted(totals map[string]int64) []ModelUsage {
304 out := make([]ModelUsage, 0, len(totals))
305 for model, t := range totals {
306 out = append(out, ModelUsage{Model: model, Provider: providerOf(model), Tokens: t})
307 }
308 sort.SliceStable(out, func(i, j int) bool {
309 if out[i].Tokens == out[j].Tokens {
310 return out[i].Model < out[j].Model
311 }
312 return out[i].Tokens > out[j].Tokens
313 })
314 return out
315 }
316
317 func providersSorted(totals map[string]int64) []ProviderUsage {
318 out := make([]ProviderUsage, 0, len(totals))
319 for prov, t := range totals {
320 out = append(out, ProviderUsage{Provider: prov, Tokens: t})
321 }
322 sort.SliceStable(out, func(i, j int) bool {
323 if out[i].Tokens == out[j].Tokens {
324 return out[i].Provider < out[j].Provider
325 }
326 return out[i].Tokens > out[j].Tokens
327 })
328 return out
329 }
330
330 lines GO