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