diff --git a/internal/lua/billing_test.go b/internal/lua/billing_test.go index 8ab9cc0..2dac77b 100644 --- a/internal/lua/billing_test.go +++ b/internal/lua/billing_test.go @@ -994,3 +994,49 @@ func TestBillingCacheHitClampedInStats(t *testing.T) { tot["cache_fresh_tokens"]) } } + +// TestUnpricedIsTrackedPerDay pins the day-keyed unpriced tally. +// +// The lifetime counters cannot answer the question an operator actually has — +// "is it STILL happening?" — because they keep the debt from before a rule was +// added. After pricing a mispriced source the lifetime number never moves, so a +// dashboard reading "unpriced: 6376" is indistinguishable from a system that is +// still billing nothing. Day buckets are what make a pricing fix observable: the +// offending day keeps its count and the next day starts at zero for it. +func TestUnpricedIsTrackedPerDay(t *testing.T) { + ps, _ := billingVM(t) + // Nothing is priced in the default config, so every request is unpriced. + // Two requests on two different UTC days. + for _, ms := range []int64{1790000000000, 1790100000000} { + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "mystery-model", "source": "nowhere", "key": "***aaaaaa", + "ok": true, "prompt_tokens": 10, "completion_tokens": 5, + "time": ms, + }) + } + st := stateOf(t, ps) + byDay, ok := st["unpriced_by_day"].(map[string]interface{}) + if !ok { + t.Fatalf("state has no unpriced_by_day map: %T", st["unpriced_by_day"]) + } + if len(byDay) != 2 { + t.Fatalf("expected 2 day buckets, got %d: %v", len(byDay), byDay) + } + var sum float64 + for day, bucket := range byDay { + b := bucket.(map[string]interface{}) + sum += b["reqs"].(float64) + models := b["models"].(map[string]interface{}) + if models["mystery-model"].(float64) != 1 { + t.Errorf("%s: model tally = %v, want 1", day, models["mystery-model"]) + } + } + if sum != 2 { + t.Errorf("day buckets sum to %v, want 2 (the lifetime counter must not be double-counted)", sum) + } + // The lifetime counters still exist and still agree — this is additive, not + // a replacement. + if st["unpriced_reqs"].(float64) != 2 { + t.Errorf("unpriced_reqs = %v, want 2", st["unpriced_reqs"]) + } +} diff --git a/internal/lua/billing_ui_test.go b/internal/lua/billing_ui_test.go index a9b49f0..9a81140 100644 --- a/internal/lua/billing_ui_test.go +++ b/internal/lua/billing_ui_test.go @@ -414,3 +414,27 @@ func TestRuleTableLocksColumnWidthsInAColgroup(t *testing.T) { "control and must never be squeezed by the flexible inputs") } } + +// TestBillingUIShowsRecentUnpriced: the page must be able to distinguish a +// settled pricing debt from a live leak. The lifetime unpriced counter answers +// neither — it keeps every request from before a rule was added, so it reads the +// same number in both situations. The day buckets exist to make the difference +// visible, and a bucket nothing renders is a bucket nobody benefits from. +func TestBillingUIShowsRecentUnpriced(t *testing.T) { + raw, err := os.ReadFile("plugins/billing.lua") + if err != nil { + t.Fatal(err) + } + src := string(raw) + for _, needle := range []string{ + "unpriced_by_day", // the tally is written + "recentUnpriced", // it is reduced to a window + "unpriced_recent", // it reaches the view + "unpriced7d", // the card labels it + "unpricedClean", // and says so when the window is clear + } { + if !strings.Contains(src, needle) { + t.Errorf("billing UI lacks %q", needle) + } + } +} diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua index 5f3e1e5..b6aa316 100644 --- a/internal/lua/plugins/billing.lua +++ b/internal/lua/plugins/billing.lua @@ -442,6 +442,21 @@ function plugin.on_request_end(payload) s.unpriced_reqs = s.unpriced_reqs + 1 local m = payload.model or "?" s.unpriced_models[m] = (s.unpriced_models[m] or 0) + 1 + + -- Day-keyed unpriced tally. The lifetime counters above cannot answer the + -- question an operator actually has — "is it STILL happening?" — because they + -- keep the debt from before a pricing rule was added. After pricing a source + -- that was mispriced, the lifetime number never moves, so "unpriced: 6376" on + -- a dashboard is indistinguishable from a system that is still billing + -- nothing. Counting by day makes the fix observable: the offending day keeps + -- its number, new days start at zero. + if s.unpriced_by_day == nil then s.unpriced_by_day = {} end + -- dayKey wants epoch SECONDS; payload.time is milliseconds. + local uday = dayKey(math.floor((tonumber(payload.time) or (os.time() * 1000)) / 1000)) + local ubucket = s.unpriced_by_day[uday] + if ubucket == nil then ubucket = { reqs = 0, models = {} }; s.unpriced_by_day[uday] = ubucket end + ubucket.reqs = ubucket.reqs + 1 + ubucket.models[m] = (ubucket.models[m] or 0) + 1 end if s.degraded_reqs == nil then s.degraded_reqs = 0 end -- Degradation is counted here rather than in the chain_step hook because @@ -517,7 +532,7 @@ plugin.ui = { global = { "total", "by_source", "by_model", "by_day", "by_day_src", "by_day_model", "by_tier_served", "skip_reasons", "degraded_reqs", - "unpriced_models", "unpriced_reqs", "started", + "unpriced_models", "unpriced_reqs", "unpriced_by_day", "started", }, depth = { by_key = 1, by_day_key = 2 }, }, @@ -593,6 +608,7 @@ plugin.ui = { en: { total: "Total", requests: "Requests", degraded: "Degraded", unpriced: "Unpriced", prompt: "Prompt tokens", + unpriced7d: "Unpriced (7d)", unpricedClean: "none in last 7 days", completion: "Completion tokens", failures: "Failures", cacheRate: "Cache hit rate", cacheTokens: "Cache read tokens", perSource: "Per source", perModel: "Per model", @@ -614,6 +630,7 @@ plugin.ui = { zh: { total: "总开销", requests: "请求数", degraded: "降级", unpriced: "未定价", prompt: "输入 tokens", + unpriced7d: "未定价(近 7 天)", unpricedClean: "近 7 天无", completion: "输出 tokens", failures: "失败", cacheRate: "缓存命中率", cacheTokens: "缓存读取 tokens", perSource: "按源", perModel: "按模型", @@ -734,13 +751,35 @@ plugin.ui = { }); return out; } + // recentUnpriced counts unpriced requests over the last n days. When the + // result is zero THAT is the useful signal: every pricing rule added since + // then is holding. Null when there is no day data at all (a state file + // written before the buckets existed) so the UI can say "unknown" rather than + // claim a clean record it cannot support. + function recentUnpriced(st, n) { + var byDay = st && st.unpriced_by_day; + if (!byDay) return null; + var cutoff = Date.now() - n * 86400000; + var sum = 0, seen = false; + Object.keys(byDay).forEach(function (d) { + var t = Date.parse(d + "T00:00:00Z"); + if (isNaN(t)) return; + seen = true; + if (t >= cutoff) sum += (byDay[d].reqs || 0); + }); + return seen ? sum : null; + } + // rescale returns the state as the selected period presents it. Degraded and // unpriced counters are lifetime-only (there is no day-keyed tally for them) // so they are returned as null and the UI hides them instead of printing a // lifetime number beside a per-window cost — the exact mismatch the period // view exists to remove. function rescale(st) { - if (PERIOD === "all" || !st.by_day) return st; + if (PERIOD === "all" || !st.by_day) { + if (st) st.unpriced_recent = recentUnpriced(st, 7); + return st; + } var days = daysInWindow(PERIOD, utcToday()); var total = zeroBucket(); var byDay = {}; @@ -758,6 +797,10 @@ plugin.ui = { by_day: byDay, degraded_reqs: null, unpriced_reqs: null, + // The lifetime unpriced total is null here for the same reason, but the + // day buckets are not — they are what lets the card report "0 in the last + // 7 days" instead of leaving a settled debt indistinguishable from a leak. + unpriced_recent: recentUnpriced(st, 7), }; return out; } @@ -838,6 +881,15 @@ plugin.ui = { // print a lifetime number next to a per-window cost. view.degraded_reqs != null ? [T.degraded, fmtInt(view.degraded_reqs)] : null, view.unpriced_reqs != null ? [T.unpriced, fmtInt(view.unpriced_reqs)] : null, + // "Unpriced (7d)" answers what the lifetime number cannot: is it STILL + // happening? A settled debt and a live leak print the same lifetime total. + view.unpriced_recent != null + ? [ + T.unpriced7d, + fmtInt(view.unpriced_recent), + view.unpriced_recent === 0 ? T.unpricedClean : null, + ] + : null, [T.prompt, fmtInt(t.prompt_tokens || 0)], [T.completion, fmtInt(t.completion_tokens || 0)], [T.failures, t.failures || 0],