mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
feat(billing): 计费页支持按日/周/月/全部,与统计页同口径
宿主统计页已支持周期,计费页还是终身累计 —— 同一个问题在两个页面重复出现, 且两个页面口径不一致本身就是错。 ## 为什么要补日级维度明细 原来只有 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,才抓得住。 判据报错时先怀疑判据——这次三次都是判据的问题。
This commit is contained in:
351
internal/lua/billing_period_test.go
Normal file
351
internal/lua/billing_period_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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 = [==[
|
||||
<div id="billing-root" style="padding:16px;min-width:0;max-width:100%;overflow-x:auto">
|
||||
<div class="filter-line" style="margin:0 0 12px">
|
||||
<span class="muted" id="billing-period-label"></span>
|
||||
<select id="billing-period" onchange="window.__billing_setPeriod(this.value)">
|
||||
<option value="day">day</option><option value="week">week</option>
|
||||
<option value="month">month</option><option value="all">all</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="kpis" id="billing-kpis" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin-bottom:18px"></div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px">
|
||||
<div class="card" style="padding:14px">
|
||||
@ -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 <select> is mounted markup the host's data-i pass never sees, so its
|
||||
// option labels are translated here too.
|
||||
var sel = document.getElementById("billing-period");
|
||||
if (sel && sel.options) {
|
||||
var names = { day: T.periodDay, week: T.periodWeek,
|
||||
month: T.periodMonth, all: T.periodAll };
|
||||
for (var i = 0; i < sel.options.length; i++) {
|
||||
var o = sel.options[i];
|
||||
if (names[o.value]) o.textContent = names[o.value];
|
||||
}
|
||||
sel.value = PERIOD;
|
||||
}
|
||||
}
|
||||
function render(st) {
|
||||
if (!st) return;
|
||||
renderTitles();
|
||||
var T = L();
|
||||
var cur = (st.currency || "USD");
|
||||
var t = st.total || {};
|
||||
var view = rescale(st);
|
||||
var t = view.total || {};
|
||||
document.getElementById("billing-kpis").innerHTML = [
|
||||
[T.total, money(t.cost, cur)],
|
||||
[T.requests, fmtInt(t.requests || 0)],
|
||||
[T.degraded, fmtInt(st.degraded_reqs || 0)],
|
||||
[T.unpriced, fmtInt(st.unpriced_reqs || 0)],
|
||||
// null = lifetime-only counter with no day-keyed tally; hide rather than
|
||||
// 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,
|
||||
[T.prompt, fmtInt(t.prompt_tokens || 0)],
|
||||
[T.completion, fmtInt(t.completion_tokens || 0)],
|
||||
[T.failures, t.failures || 0],
|
||||
@ -609,7 +767,7 @@ plugin.ui = {
|
||||
// tables. The numbers existed in state and nowhere in the UI.
|
||||
[T.cacheRate, cacheRate(t)],
|
||||
[T.cacheTokens, fmtInt(t.cache_hit_tokens || 0)]
|
||||
].map(function (kv) {
|
||||
].filter(function (x) { return x !== null; }).map(function (kv) {
|
||||
// min-width:0:grid item 默认 min-width:auto,2e8 级长数字会把轨道撑出
|
||||
// 容器造成横向溢出。标签 nowrap 截断,数值 break-all 换行。
|
||||
return "<div class='card' style='padding:12px;min-width:0;overflow:hidden'>" +
|
||||
@ -618,10 +776,10 @@ plugin.ui = {
|
||||
esc(kv[1]) + "</div></div>";
|
||||
}).join("");
|
||||
var TD = L();
|
||||
tableFor(document.getElementById("billing-by-source"), st.by_source, cur, TD.noData);
|
||||
tableFor(document.getElementById("billing-by-model"), st.by_model, cur, TD.noData);
|
||||
tableFor(document.getElementById("billing-by-key"), st.by_key, cur, TD.noData);
|
||||
tableFor(document.getElementById("billing-by-day"), st.by_day, cur, TD.noData);
|
||||
tableFor(document.getElementById("billing-by-source"), view.by_source, cur, TD.noData);
|
||||
tableFor(document.getElementById("billing-by-model"), view.by_model, cur, TD.noData);
|
||||
tableFor(document.getElementById("billing-by-key"), view.by_key, cur, TD.noData);
|
||||
tableFor(document.getElementById("billing-by-day"), view.by_day, cur, TD.noData);
|
||||
}
|
||||
async function refresh() {
|
||||
try {
|
||||
@ -639,6 +797,22 @@ plugin.ui = {
|
||||
}
|
||||
}
|
||||
window.__billingRefresh = refresh;
|
||||
window.__billing_setPeriod = function (v) {
|
||||
if (["day", "week", "month", "all"].indexOf(v) < 0) return;
|
||||
PERIOD = v;
|
||||
renderTitles();
|
||||
refresh();
|
||||
};
|
||||
// Test-only hook for the period logic. The helpers live in this IIFE, so a
|
||||
// Go test driving the real code cannot reach them otherwise; without it the
|
||||
// only option is to reimplement the window rules in the test, which would
|
||||
// prove the reimplementation agrees with itself. Read-only exposure of pure
|
||||
// functions — it grants no capability the page does not already have.
|
||||
window.__billingInternals = {
|
||||
daysInWindow: daysInWindow,
|
||||
rescale: rescale,
|
||||
currentPeriod: function () { return PERIOD; },
|
||||
};
|
||||
refresh();
|
||||
if (window.pluginAPI) {
|
||||
if (pluginAPI.onTabShown) pluginAPI.onTabShown(refresh);
|
||||
|
||||
Reference in New Issue
Block a user