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