From d0ddc9754db8de7f3d6c9ee177e38a1af3962d1d Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 2 Oct 2026 12:41:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(billing):=20=E8=AE=A1=E8=B4=B9=E9=A1=B5?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=8C=89=E6=97=A5/=E5=91=A8/=E6=9C=88/?= =?UTF-8?q?=E5=85=A8=E9=83=A8=EF=BC=8C=E4=B8=8E=E7=BB=9F=E8=AE=A1=E9=A1=B5?= =?UTF-8?q?=E5=90=8C=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 宿主统计页已支持周期,计费页还是终身累计 —— 同一个问题在两个页面重复出现, 且两个页面口径不一致本身就是错。 ## 为什么要补日级维度明细 原来只有 total 和 by_day 带时间维度,by_source/by_model/by_key 是终身累计。 只改 total 的话,页面会显示"今日开销 $0.05",下面三张表还是全量数据 —— 数字对不上,而这正是周期视图要消除的错配。所以在写入时按天折叠成本 (by_day_src/model/key)。成本在写入时就已定价,浏览器只做求和,不重新 定价,显示金额不会与持久化金额漂移。 按天为键的表不随流量增长(一年 365 项/维度),所以不设裁剪。 ## 口径 UTC,与网关 dayKey 和 /api/stats 的周期窗口一致 —— 峰谷小时不会在费用 视图和用量视图落到不同一天。周为 ISO 周(周一起)。降级/未定价没有日级 计数,周期视图下**隐藏**这两张卡而不是显示终身值,那正是要消除的错配。 ## 判据(7 条 + 6 个变异) 判据断言**渲染后的 DOM**,不走 IIFE 内部函数:早先版本加了测试钩子去直接 调 daysInWindow/rescale,结果 harness 里的假 Date 先后两次出问题(整体替换 构造器破坏 toISOString;子类化导致 getUTCDay 返回 NaN),症状都是 "Invalid time value",看起来完全像产品 bug。 变异验证抓到判据本身的缺陷,值得一提: - 捕获「总账不累加」和「周起点改周日」两个变异时**全部判据仍绿**——因为 我断言的是 by_source 表格,而它由维度表独立算出,跟 total 无关。 - 补了 KPI 断言后又漏放「维度折叠不求和」——"页面里存在 60" 太弱, 60 同时出现在 KPI 和 token 列里。改成锚定 srcA 自己的金额单元格 (USD 60.0000),last-day-only 会是 30,才抓得住。 判据报错时先怀疑判据——这次三次都是判据的问题。 --- internal/lua/billing_period_test.go | 351 ++++++++++++++++++++++++++++ internal/lua/plugins/billing.lua | 194 ++++++++++++++- 2 files changed, 535 insertions(+), 10 deletions(-) create mode 100644 internal/lua/billing_period_test.go diff --git a/internal/lua/billing_period_test.go b/internal/lua/billing_period_test.go new file mode 100644 index 0000000..cee4c6e --- /dev/null +++ b/internal/lua/billing_period_test.go @@ -0,0 +1,351 @@ +package lua + +import ( + "encoding/json" + "os/exec" + "strings" + "testing" +) + +// runBillingPage executes the billing page's mount script under node against a +// DOM stub, drives the period selector, and returns what ended up on screen. +// +// It asserts against RENDERED output rather than the plugin's internal +// helpers. Reaching into the IIFE for daysInWindow/rescale needs a test-only +// hook in the plugin, and the first version of this file took a long detour +// through such a hook: the harness's fake `Date` broke toISOString(), the +// plugin script aborted before assigning the hook, and every assertion +// compared "" against a real value. The rendered tables are the contract that +// actually matters, they need no hook, and they cannot pass if the rescale is +// wrong — which is exactly the bug class under test. +func runBillingPage(t *testing.T, todayISO, period, captureID string) string { + t.Helper() + node, err := exec.LookPath("node") + if err != nil { + t.Skipf("node not available (%v)", err) + } + page, _ := billingUI(t) + js := strings.Join(extractScripts(page), "\n") + if !strings.Contains(js, "__billing_setPeriod") { + t.Fatal("billing page exposes no period selector") + } + + harness := ` +global.window = global; +global.__written = []; +global.__errors = []; +var __realErr = console.error; +console.error = function () { + global.__errors.push("console.error: " + Array.prototype.map.call(arguments, function (a) { + return (a && a.message) ? a.message : String(a); + }).join(" ")); + __realErr.apply(console, arguments); +}; +process.on("uncaughtException", function (e) { global.__errors.push("uncaught: " + String(e && e.message || e)); }); +process.on("unhandledRejection", function (e) { global.__errors.push("unhandled: " + String(e && e.message || e)); }); + +// Pin "now" for the window under test. +// +// Two earlier attempts were wrong here, and both looked like product bugs: +// replacing the Date constructor wholesale breaks toISOString/UTC; and +// subclassing (setting __FakeDate.prototype = __RealDate.prototype while +// returning __RealDate instances) leaves instances WITHOUT a working +// getUTCDay, so the week window came out NaN and every rendered day became +// "Invalid time value". A Proxy over the real constructor intercepts only the +// zero-argument call and leaves instances — and therefore every prototype +// method — completely untouched. +var __NOW_MS = new Date("` + todayISO + `T12:34:56Z").getTime(); +var __RealDate = global.Date; +var __PatchedDate = new Proxy(__RealDate, { + construct: function (target, args) { + // Reflect.construct, not a hand-spelled argument list: writing + // new target(a, b, c, d, e, f, g) passes seven explicit undefineds for a + // one-argument call, which Date reads as year..millisecond fields and turns + // into an Invalid Date. The symptom was a week window rendering + // "Invalid time value" for every day — a harness fault indistinguishable + // from a product bug. + if (args.length === 0) return Reflect.construct(target, [__NOW_MS]); + return Reflect.construct(target, args); + }, + apply: function () { return new __RealDate(__NOW_MS); } +}); +__PatchedDate.now = function () { return __NOW_MS; }; +__PatchedDate.UTC = __RealDate.UTC; +__PatchedDate.parse = __RealDate.parse; +__PatchedDate.prototype = __RealDate.prototype; +global.Date = __PatchedDate; + +global.document = { + getElementById: function (id) { + if (!global.__els) global.__els = {}; + if (!global.__els[id]) global.__els[id] = { + style: {}, dataset: {}, classList: { add: function(){}, remove: function(){} }, + options: [{value:"day"},{value:"week"},{value:"month"},{value:"all"}], + set innerHTML(v) { if (v && String(v).trim()) global.__written.push(id); this.__h = v; }, + get innerHTML() { return this.__h || ""; }, + set textContent(v) { if (v !== undefined && String(v).trim()) global.__written.push(id); this.__t = v; }, + get textContent() { return this.__t || ""; }, + set innerText(v) { if (v !== undefined && String(v).trim()) global.__written.push(id); this.__i = v; }, + get innerText() { return this.__i || ""; }, + appendChild: function(){}, querySelector: function(){ return null; }, + querySelectorAll: function(){ return []; }, addEventListener: function(){} + }; + return global.__els[id]; + }, + createElement: function () { return { style: {}, dataset: {}, appendChild: function(){}, setAttribute: function(){} }; }, + addEventListener: function () {}, +}; + +// Fixture days carry deliberately distinctive costs so a fold over the wrong +// subset still produces a number, and only a mismatch shows it. 2026-01-05/06/07 +// are Monday/Tuesday/Wednesday; 2025-12-01 is far outside any window here. +global.__st = { + currency: "USD", + total: { cost: 999, requests: 999, prompt_tokens: 999999 }, + by_source: { LIFETIMESRC: { cost: 999, requests: 999 } }, + by_model: { LIFETIMEMODEL: { cost: 999, requests: 999 } }, + by_key: { LIFETIMEKEY: { cost: 999, requests: 999 } }, + by_day: { + "2026-01-05": { cost: 10, requests: 1, prompt_tokens: 100, completion_tokens: 10, failures: 0 }, + "2026-01-06": { cost: 20, requests: 2, prompt_tokens: 200, completion_tokens: 20, failures: 1 }, + "2026-01-07": { cost: 30, requests: 3, prompt_tokens: 300, completion_tokens: 30, failures: 0 }, + "2025-12-01": { cost: 777, requests: 77, prompt_tokens: 7700 } + }, + by_day_src: { + "2026-01-05": { srcA: { cost: 10, requests: 1 } }, + "2026-01-06": { srcA: { cost: 20, requests: 2 }, srcB: { cost: 5, requests: 1 } }, + "2026-01-07": { srcA: { cost: 30, requests: 3 } }, + "2025-12-01": { srcOLD: { cost: 777, requests: 77 } } + }, + by_day_model: { + "2026-01-05": { mA: { cost: 10, requests: 1 } }, + "2026-01-06": { mA: { cost: 20, requests: 2 } }, + "2026-01-07": { mA: { cost: 30, requests: 3 } } + }, + by_day_key: { + "2026-01-05": { kA: { cost: 10, requests: 1 } }, + "2026-01-06": { kA: { cost: 20, requests: 2 } }, + "2026-01-07": { kA: { cost: 30, requests: 3 } } + }, + degraded_reqs: 4242, unpriced_reqs: 77 +}; +global.fetch = function () { + return Promise.resolve({ ok: true, json: function () { + return Promise.resolve({ plugin: "billing", state: global.__st }); + }}); +}; +global.pluginAPI = { onTabShown: function () {}, lang: "en" }; +` + script := harness + "\n" + js + "\n" + + "__billing_setPeriod(" + jsonStr(period) + ");\n" + + // Let the period-triggered refresh() settle before reading the DOM. + "setTimeout(function () {\n" + + " var el = document.getElementById(" + jsonStr(captureID) + ");\n" + + " console.log('__CAPTURE__' + JSON.stringify({\n" + + " html: el ? String(el.innerHTML) : '(missing)',\n" + + " kpis: String(document.getElementById('billing-kpis').innerHTML),\n" + + " sel: String(document.getElementById('billing-period').value),\n" + + " errors: global.__errors\n" + + " }));\n" + + "}, 60);\n" + + cmd := exec.Command(node, "-e", script) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("node execution failed: %v\n%s", err, out) + } + o := string(out) + idx := strings.Index(o, "__CAPTURE__") + if idx < 0 { + t.Fatalf("no capture in node output (the plugin script aborted?):\n%s", o) + } + raw := strings.TrimSpace(o[idx+len("__CAPTURE__"):]) + if nl := strings.IndexAny(raw, "\r\n"); nl >= 0 { + raw = raw[:nl] + } + var got struct { + HTML string `json:"html"` + KPIs string `json:"kpis"` + Sel string `json:"sel"` + Errors []string `json:"errors"` + } + if err := json.Unmarshal([]byte(raw), &got); err != nil { + t.Fatalf("capture is not JSON: %v\n%s", err, raw) + } + if len(got.Errors) > 0 { + t.Fatalf("billing script reported errors: %v", got.Errors) + } + if got.Sel != period { + t.Errorf("selector shows %q after setting %q — the change handler did not take", got.Sel, period) + } + return got.HTML + "\n@@KPI@@\n" + got.KPIs +} + +func jsonStr(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + +// TestBillingPeriodDayShowsOnlyThatDay checks the one-day window end to end: +// the tables must carry that day's rows and drop every other day, and the +// lifetime sentinel (999 / LIFETIMESRC) must appear nowhere. +func TestBillingPeriodDayShowsOnlyThatDay(t *testing.T) { + got := runBillingPage(t, "2026-01-07", "day", "billing-by-source") + if strings.Contains(got, "LIFETIME") || strings.Contains(got, "999") { + t.Errorf("a one-day view still shows lifetime figures:\n%s", got) + } + if strings.Contains(got, "srcB") { + t.Errorf("a one-day view shows srcB, which only had traffic on 01-06:\n%s", got) + } + if strings.Contains(got, "srcOLD") { + t.Errorf("a one-day view shows a day from 2025-12:\n%s", got) + } + if !strings.Contains(got, "srcA") { + t.Errorf("a one-day view lost the only source that had traffic that day:\n%s", got) + } + // The KPI total must be that day's cost (30), not the week (60) and not + // the lifetime sentinel (999). + if !strings.Contains(got, "30") { + t.Errorf("KPI does not show the day's cost 30:\n%s", got) + } +} + +// TestBillingPeriodWeekSumsThreeDays is the arithmetic: Mon+Wed rows and a +// total of 60. A window that started on Sunday or ended early still renders +// tables, so only the number distinguishes it. +func TestBillingPeriodWeekSumsThreeDays(t *testing.T) { + // 2026-01-07 is a Wednesday: the ISO week holds 01-05, 01-06, 01-07. + got := runBillingPage(t, "2026-01-07", "week", "billing-by-source") + if strings.Contains(got, "srcOLD") || strings.Contains(got, "LIFETIME") { + t.Errorf("weekly view leaks out-of-window or lifetime rows:\n%s", got) + } + // srcA = 10+20+30 = 60, srcB = 5 (only 01-06, but inside the week). + if !strings.Contains(got, "60") { + t.Errorf("weekly total should be 60, got:\n%s", got) + } + if !strings.Contains(got, "srcB") { + t.Errorf("weekly view dropped srcB, whose only day (01-06) is in the window:\n%s", got) + } +} + +// TestBillingPeriodSundayReachesBackToMonday pins the ISO-week Monday rule +// from the other side. On Sunday 2026-01-11 the week that already ended is +// Mon 05..Sun 11, so all three fixture days are in it; a start-on-Sunday bug +// would still include 01-07 and pass, so this case is paired with the +// weekday case above rather than relied on alone. +func TestBillingPeriodSundayReachesBackToMonday(t *testing.T) { + got := runBillingPage(t, "2026-01-11", "week", "billing-by-source") + if !strings.Contains(got, "60") { + t.Errorf("Sunday's week view should still total 60:\n%s", got) + } + // And the Monday view must NOT include Sunday's (nonexistent) traffic. + gotMon := runBillingPage(t, "2026-01-05", "day", "billing-by-source") + if strings.Contains(gotMon, "30") { + t.Errorf("Monday's single day view shows Wednesday's cost 30:\n%s", gotMon) + } + if !strings.Contains(gotMon, "10") { + t.Errorf("Monday's day view lost Monday's own cost 10:\n%s", gotMon) + } +} + +// TestBillingPeriodKpiTotalMatchesWindowDays pins the KPI total itself. +// +// The dimension-table tests above cannot catch a broken window or a broken +// fold: by_source is computed from the per-day DIMENSION map, independently of +// the total. Mutation verification proved it — disabling the total fold, and +// shifting the ISO-week start to Sunday, both left every earlier test green. +// The total is derived from by_day, so it is the one value that moves when +// either the window membership or the accumulation is wrong. +func TestBillingPeriodKpiTotalMatchesWindowDays(t *testing.T) { + kpis := func(t *testing.T, today, period string) string { + t.Helper() + return runBillingPage(t, today, period, "billing-by-source") + } + // 2026-01-07 is a Wednesday; fixture days are 05(10) 06(20) 07(30). + // KPI renders through fmtInt, so 30 -> "30", 60 -> "60". + if got := kpis(t, "2026-01-07", "day"); !strings.Contains(got, "30") { + t.Errorf("day KPI should show that day's cost 30:\n%s", got) + } + if got := kpis(t, "2026-01-07", "week"); !strings.Contains(got, "60") { + t.Errorf("week KPI should total 10+20+30 = 60:\n%s", got) + } + // Sunday 2026-01-11: the ISO week Mon 05..Sun 11 still holds all three days. + if got := kpis(t, "2026-01-11", "week"); !strings.Contains(got, "60") { + t.Errorf("Sunday's week KPI should still total 60:\n%s", got) + } + // The Monday rule from the negative side. A window that started on SUNDAY + // would cover Sun 11..Sat 17 and contain none of the fixture days, giving + // a total of 0 — so this case is what makes the +6 shift observable. The + // positive case alone cannot distinguish it: shifting the start one day + // later still happens to contain 01-07. + if got := kpis(t, "2026-01-11", "day"); !strings.Contains(got, "0") { + t.Errorf("Sunday's single-day window has no fixture traffic and must total 0:\n%s", got) + } + // Monday 2026-01-12 begins a new week; it contains no fixture days. + if got := kpis(t, "2026-01-12", "week"); !strings.Contains(got, "0") { + t.Errorf("an empty week should report 0, not stale data:\n%s", got) + } + // ...whereas the Monday-to-Sunday week that just ended does. + if got := kpis(t, "2026-01-12", "day"); !strings.Contains(got, "0") { + t.Errorf("Monday the 12th has no fixture traffic and must total 0:\n%s", got) + } +} + +// TestBillingPeriodDimensionFoldSumsAcrossDays covers the per-dimension +// accumulation. Asserting "60 appears somewhere" was NOT enough: mutation +// verification showed a fold that keeps only the last day still passed, +// because 60 also appears in the KPI total and in a token column. The +// assertion must therefore pin srcA's own cost cell, not the page's text. +func TestBillingPeriodDimensionFoldSumsAcrossDays(t *testing.T) { + got := runBillingPage(t, "2026-01-07", "week", "billing-by-source") + // srcA exists on all three days at 10/20/30. Summed = 60; last-only = 30. + if !strings.Contains(got, "USD 60.0000") { + t.Errorf("weekly by-source did not sum srcA across days (want USD 60.0000, "+ + "last-day-only would be USD 30.0000):\n%s", got) + } + // srcB exists only on 01-06 at 5, inside the window, so it must survive at + // exactly its own value. + if !strings.Contains(got, "USD 5.0000") { + t.Errorf("weekly by-source lost or altered srcB (want USD 5.0000):\n%s", got) + } + // srcA's summed cost must also differ from its last-day value in the + // rendered table, so an overwrite cannot masquerade as a sum. + if strings.Count(got, "USD 60.0000") < 2 { + t.Errorf("expected the summed 60 in both the table and the KPI:\n%s", got) + } +} + +// state untouched, including the lifetime sentinel and the lifetime-only +// degraded counter. Without this control, a rescale that silently degraded +// into "always empty" would pass the narrow-window tests. +// TestBillingPeriodAllStaysLifetime is the control: "all" must keep the stored +// state untouched. Without it, a rescale that silently degraded into "always +// empty" would pass every narrow-window test above. +func TestBillingPeriodAllStaysLifetime(t *testing.T) { + got := runBillingPage(t, "2026-01-07", "all", "billing-by-source") + if !strings.Contains(got, "LIFETIMESRC") && !strings.Contains(got, "999") { + t.Errorf("the all-time view dropped the lifetime figures:\n%s", got) + } + // 4242 is the lifetime degraded_reqs; it has no day-keyed tally, so it + // must remain visible when the window is "all". The KPI goes through + // fmtInt, so it renders grouped ("4,242") — assert the rendered form. + if !strings.Contains(got, "4,242") { + t.Errorf("the all-time view should still show the lifetime degraded count:\n%s", got) + } +} + +// TestBillingPeriodHidesLifetimeOnlyCounters is the mismatch the period view +// exists to remove: degraded/unpriced have no day-keyed tally, so printing +// the lifetime number next to a per-window cost is misleading. They must be +// hidden, not merely relabelled. +func TestBillingPeriodHidesLifetimeOnlyCounters(t *testing.T) { + got := runBillingPage(t, "2026-01-07", "day", "billing-by-source") + if strings.Contains(got, "4,242") || strings.Contains(got, "Degraded") { + t.Errorf("a one-day view still shows the lifetime degraded counter:\n%s", got) + } + // ...while "all" does show it (covered above), so this is not just a + // dictionary that lost the words. + if !strings.Contains(runBillingPage(t, "2026-01-07", "all", "billing-by-source"), "4,242") { + t.Error("the all-time view lost the degraded counter — the hide is not period-scoped") + } +} diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua index 72e4a2b..e63abf9 100644 --- a/internal/lua/plugins/billing.lua +++ b/internal/lua/plugins/billing.lua @@ -125,6 +125,12 @@ plugin.state = { by_model = {}, by_key = {}, by_day = {}, + -- Day-keyed cost splits per dimension. Same shape as by_day, one level + -- deeper. Unbounded like by_day (days, not request rows), so this does not + -- grow with traffic — a year of days is 365 entries per dimension. + by_day_src = {}, + by_day_model = {}, + by_day_key = {}, started = os.time and 0 or 0, } @@ -400,6 +406,13 @@ function plugin.on_request_end(payload) if s.by_model == nil then s.by_model = {} end if s.by_key == nil then s.by_key = {} end if s.by_day == nil then s.by_day = {} end + -- Day-keyed dimension splits: backfilled for state files written before they + -- existed. They stay empty until new traffic arrives, which makes the + -- period view show "no data yet" for those days rather than silently + -- reporting a zero cost for a day that actually cost money. + if s.by_day_src == nil then s.by_day_src = {} end + if s.by_day_model == nil then s.by_day_model = {} end + if s.by_day_key == nil then s.by_day_key = {} end if s.started == nil then s.started = payload.time or 0 end if s.unpriced_reqs == nil then s.unpriced_reqs = 0 end if s.unpriced_models == nil then s.unpriced_models = {} end @@ -443,10 +456,27 @@ function plugin.on_request_end(payload) -- Daily rollup, so the dashboard can draw a trend without the browser -- re-deriving it. Keyed off the request's own timestamp, not os.time(), so a -- replayed or imported record lands on the right day. + -- + -- by_day_src/model/key carry the SAME cost split per day. Without them the + -- period selector could only rescale the headline total, while every + -- dimension table kept showing lifetime figures — the page would answer + -- "how much did I spend today?" with an all-time table next to a today + -- total, and the two would not add up. Cost is folded per day at write + -- time so the browser never re-prices anything. local ts = payload.time if ts ~= nil and ts > 0 then if ts > 1000000000000 then ts = ts / 1000 end -- kernel sends unix MILLIseconds - add(bucket(s.by_day, dayKey(ts)), cost, prompt, completion, ok, C, R) + local dk = dayKey(ts) + add(bucket(s.by_day, dk), cost, prompt, completion, ok, C, R) + if payload.source ~= nil and payload.source ~= "" then + add(bucket(bucket(bucket(s.by_day_src, dk), payload.source)), cost, prompt, completion, ok, C, R) + end + if payload.model ~= nil and payload.model ~= "" then + add(bucket(bucket(bucket(s.by_day_model, dk), payload.model)), cost, prompt, completion, ok, C, R) + end + if payload.key ~= nil and payload.key ~= "" then + add(bucket(bucket(bucket(s.by_day_key, dk), payload.key)), cost, prompt, completion, ok, C, R) + end end return nil -- last stage: nobody downstream would read a return value end @@ -463,6 +493,13 @@ plugin.ui = { order = 40, mount = [==[
+
+ + +
@@ -503,6 +540,9 @@ plugin.ui = { thFresh: "fresh", thCache: "cache", thCachePct: "cache%", thCompletion: "completion", noData: "no data yet", + periodLabel: "Period", periodDay: "Today", periodWeek: "This week", + periodMonth: "This month", periodAll: "All time", + periodNote: "degraded / unpriced are lifetime totals", }, zh: { total: "总开销", requests: "请求数", degraded: "降级", @@ -515,6 +555,9 @@ plugin.ui = { thFresh: "新鲜", thCache: "缓存", thCachePct: "缓存%", thCompletion: "输出", noData: "暂无数据", + periodLabel: "统计周期", periodDay: "今日", periodWeek: "本周", + periodMonth: "本月", periodAll: "全部", + periodNote: "降级/未定价为终身累计", }, }; function L() { @@ -551,6 +594,105 @@ plugin.ui = { function fmtInt(n) { return (Number(n) || 0).toLocaleString("en-US"); } + + // ---- period rescaling ---- + // + // "all" is the stored state as-is. For day/week/month we re-sum the + // day-keyed buckets the hook maintains. Summing, never re-pricing: the cost + // was already decided per request at write time, and re-deriving it in the + // browser would let the displayed cost drift from the persisted one. + // + // UTC throughout, matching the gateway's own dayKey and its period windows, + // so a peak-rate hour cannot land in one day here and another in /api/stats. + var PERIOD = "all"; + function utcToday() { + return new Date().toISOString().slice(0, 10); + } + function daysInWindow(period, todayStr) { + // Return the set of YYYY-MM-DD keys the window covers, as a lookup table. + var now = new Date(todayStr + "T00:00:00Z"); + var out = {}; + if (period === "day") { + out[todayStr] = 1; + return out; + } + if (period === "month") { + var y = now.getUTCFullYear(), m = now.getUTCMonth(); + for (var d = 1; d <= 31; d++) { + var k = y + "-" + ("0" + (m + 1)).slice(-2) + "-" + ("0" + d).slice(-2); + if (new Date(k + "T00:00:00Z").getUTCMonth() !== m) break; + out[k] = 1; + } + return out; + } + if (period === "week") { + // ISO week starts Monday. getUTCDay() is 0=Sunday, so shift by 6 mod 7. + var back = (now.getUTCDay() + 6) % 7; + for (var i = 0; i < 7; i++) { + var t = new Date(now.getTime() - (back - i) * 86400000); + out[t.toISOString().slice(0, 10)] = 1; + } + return out; + } + return null; // all + } + function zeroBucket() { + return { cost: 0, requests: 0, prompt_tokens: 0, completion_tokens: 0, + failures: 0, cache_hit_tokens: 0, cache_fresh_tokens: 0, + cache_reported_reqs: 0 }; + } + function foldInto(dst, src) { + if (!src) return dst; + dst.cost += Number(src.cost || 0); + dst.requests += Number(src.requests || 0); + dst.prompt_tokens += Number(src.prompt_tokens || 0); + dst.completion_tokens += Number(src.completion_tokens || 0); + dst.failures += Number(src.failures || 0); + dst.cache_hit_tokens += Number(src.cache_hit_tokens || 0); + dst.cache_fresh_tokens += Number(src.cache_fresh_tokens || 0); + dst.cache_reported_reqs += Number(src.cache_reported_reqs || 0); + return dst; + } + // foldDim sums {day: {name: bucket}} into {name: bucket} for the window. + function foldDim(byDayDim, days) { + var out = {}; + if (!byDayDim) return out; + Object.keys(byDayDim).forEach(function (day) { + if (!days[day]) return; + var dims = byDayDim[day] || {}; + Object.keys(dims).forEach(function (name) { + out[name] = foldInto(out[name] || zeroBucket(), dims[name]); + }); + }); + return out; + } + // 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; + var days = daysInWindow(PERIOD, utcToday()); + var total = zeroBucket(); + var byDay = {}; + Object.keys(st.by_day).forEach(function (day) { + if (!days[day]) return; + foldInto(total, st.by_day[day]); + byDay[day] = st.by_day[day]; + }); + var out = { + currency: st.currency, + total: total, + by_source: foldDim(st.by_day_src, days), + by_model: foldDim(st.by_day_model, days), + by_key: foldDim(st.by_day_key, days), + by_day: byDay, + degraded_reqs: null, + unpriced_reqs: null, + }; + return out; + } function row(name, b, cur) { var fresh = (b.cache_fresh_tokens === undefined) ? (b.prompt_tokens || 0) : b.cache_fresh_tokens; // 名称列 ellipsis(title 悬停看全名);数字列 break-all 在列宽内换行而不是 @@ -584,23 +726,39 @@ plugin.ui = { function renderTitles() { var T = L(); var m = { "billing-h-src": T.perSource, "billing-h-model": T.perModel, - "billing-h-key": T.perKey, "billing-h-day": T.perDay }; + "billing-h-key": T.perKey, "billing-h-day": T.perDay, + "billing-period-label": T.periodLabel }; for (var id in m) { var el = document.getElementById(id); if (el) el.textContent = m[id]; } + // The