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:
JianFeeeee
2026-10-02 12:41:35 +08:00
parent c241a19b51
commit d0ddc9754d
2 changed files with 535 additions and 10 deletions

View File

@ -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);