From 27b908d98c3154c35a96f87937e62d4626af84d9 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 4 Oct 2026 12:34:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(billing):=20=E6=9C=AA=E5=AE=9A=E4=BB=B7?= =?UTF-8?q?=E6=8C=89=E5=A4=A9=E7=BB=9F=E8=AE=A1=EF=BC=8C=E8=AE=A9=E3=80=8C?= =?UTF-8?q?=E5=B7=B2=E4=BF=AE=E5=A5=BD=E3=80=8D=E5=92=8C=E3=80=8C=E8=BF=98?= =?UTF-8?q?=E5=9C=A8=E6=BC=8F=E3=80=8D=E4=B8=8D=E5=86=8D=E9=95=BF=E5=BE=97?= =?UTF-8?q?=E4=B8=80=E6=A0=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 计费页只有一个终身计数器 unpriced_reqs,实测 6378 —— 占 18981 请求的 34%。 但这个数字回答不了运维唯一会问的问题:「还在漏吗?」 原因是它把两件事混在一起:加规则之前的历史欠账,和加规则之后仍在发生的漏计。 修好一个源之后终身计数纹丝不动,于是仪表盘上「未定价 6378」和一个仍在 什么都没记的系统完全无法区分。 改动: - 钩子按 UTC 天写入 unpriced_by_day{day:{reqs, models}}。终身计数保留 (报表不该改写历史),天桶是新增的观察维度。 - 页面加「未定价(近 7 天)」一行。为 0 时额外显示「近 7 天无」——这时候 零本身就是结论:加的规则确实生效了。 - 没有天桶数据时返回 null 而不是 0。旧的 state 文件说「未知」, 不该假装有一份干净记录。 生产实测:全部视图 终身 6378 / 近 7 天 2;日视图隐藏终身计数(null)但保留 近 7 天 —— 周期视图原本把两者都置 null,因为那时根本没有天桶。 失败的请求同样计入(上游 502 的 gpt-5.5 也进了天桶):一次失败的请求照样 消耗上游配额,只是没拿到 token,单看 token 维度会漏掉。 判据:两个不同日期的请求必须落到两个天桶且终身计数不重复累加;变异成 「总记今天」即变红(只判首日会漏掉这一点)。 Co-Authored-By: ModelRouter (cherry picked from commit ba6de89d57a976ad3b7b83c9f5526907ce3daa92) --- internal/lua/billing_test.go | 46 ++++++++++++++++++++++++++ internal/lua/billing_ui_test.go | 24 ++++++++++++++ internal/lua/plugins/billing.lua | 56 ++++++++++++++++++++++++++++++-- 3 files changed, 124 insertions(+), 2 deletions(-) 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],