| 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 |