fix(billing): 缓存命中统计缺失 + Billing 页空白 + 侧栏图标

三个问题都来自生产实测,不是代码审阅。

## 1. 缓存命中被计费却不被统计
网关确实从上游 usage 提取了 prompt_cache_hit_tokens(审计里能看到
cache_hit_tokens: 270104 / cache_reported: true,占 prompt 的 99.9%),
costFor() 也用它给缓存段定价了 —— 但**没有任何 bucket 记录它**。
结果:一个 99.88% 命中率的网关,报表显示 prompt_tokens 却看不出其中
多少是缓存读,也无从按源/模型/key 看命中率。

每个 bucket 现在多三个字段:
  cache_hit_tokens    命中数(按上游上报)
  cache_fresh_tokens  未命中的 prompt
  cache_reported_reqs 上游确实上报了缓存数的请求数

第三个字段是刻意的:**「零命中」与「上游根本不上报」在命中总量里完全一样**,
而它们在「缓存折扣有没有生效」这个问题上含义相反。没有它就无法区分,
只能猜。

chat.go 的 payload 之前**没有** cache_reported(审计有、插件没有),
所以任何插件侧的缓存统计都只能猜 —— 已补上。

旧 state 文件的 bucket 没有这些字段:Lua 里 nil + number 会抛错,而钩子抛错
会让**该请求完全不记账**(一个统计缺口会变成静默缺口)。add() 里做了回填。

UI 增加 fresh/cache/cache% 三列 + Cache hit rate KPI;未上报的显示 n/r 而不是 0%。

## 2. Billing 页空白:render() 引用了未定义的 s
`render(st)` 里两处 KPI 写成 `s.degraded_reqs`,ReferenceError 让整个渲染
中断,所有表格停在初始的空 innerHTML。症状是「页面加载了但什么都没有」,
而 /api/plugins/billing/state 返回 200 且有真实数据 —— 载荷完全正确,
DOM 是空的。

更糟的是 refresh() 里的 `catch (e) { /* never break the page */ }` 把错误
**静默吞掉**了:网络面板一切正常,页面什么都没有。现在 catch 会
console.error(仍然不抛,装饰性组件不该拖垮宿主页,但必须留痕)。

## 3. 侧栏图标
billing 声明 icon = "💰",而原生 tab 全是内联 SVG(stroke: currentColor)。
emoji 尺寸不对、不跟随主题。

WebUI 增加 pluginIconHTML:插件图标可以是文本,也可以是内联 SVG。
**SVG 走严格白名单**(tag + 属性都是 allowlist,不是 denylist)——
插件是在运维者浏览器里跑的第三方代码,不能"信任插件";但也不能直接拒绝
SVG,因为那是唯一能和原生 tab 视觉一致的方式。

用真实 Chromium 验证 12 个用例,全部挡住,包括 foreignObject 里嵌 HTML
命名空间 <img onerror> 这个经典绕过(整体丢弃,所以 img/onerror 也没了)。
★ node 里没有 DOMParser/jsdom,所以没法在单测里跑这个过滤器 —— 用正则近似
会得到一个"测试通过但浏览器里失效"的过滤器,这比没有测试更糟。

顺带修了过滤器的两个真缺陷:输出里嵌套了空 `<svg></svg>`,且 viewBox
是从包装元素读的(永远是 null)而不是插件自己的,所以任何自定义 viewBox
的图标都会丢失。

## 判据(新增 7 项,全部变异验证)
写「注入脚本能否正常执行」这个守卫时我错了四次:
  1. 静态扫「已声明的名字」→ 把 HTML 字符串里的 CSS 类名(class/div/td)
     全报成未定义
  2. 用 CSS 选择器解析器查样式表 → 报样式表本身坏了
  3. 只挂 process 的 uncaughtException → 脚本在 IIFE 里异步跑,错误是
     unhandledRejection,判据对原 bug 全绿
  4. 只查「有没有抛错」→ render() 开头是 `if (!st) return`,传错字段是
     **静默 no-op**:不抛、不打日志、不报错,只是页面空白
最终判据是:在 node 里用 DOM stub 真跑一遍,同时要求「无异常」且
「至少写进一个容器」,并监听 console.error。变异验证:还原 s → 红;
render 收到 undefined 字段 → 红。

表头/行列数一致性也有守卫:row() 加了缓存列而表头没加时,表格会整体错位
(cache% 落到 completion 列下)—— 渲染正常、有数据、但要仔细看才发现。

## 生产验证
重启后价目表与累计账完整保留(1.17 亿 prompt tokens)。
新请求缓存统计生效:cache_hit 947,436 / cache_fresh 888,
cache_reported_reqs 7 / 395(其余来自旧 state,正是该字段存在的意义)。
真实浏览器:表格 3 行、KPI 7 项、表头 name/cost/reqs/prompt/fresh/cache/cache%/completion、
SVG 图标 currentColor 渲染、控制台无 billing 错误。391 个测试全绿。

## 另发现一个无关 bug(未修)
首页 stats 图表抛 IndexSizeError: arc 半径为负(-2),在 ui/index.html 的
paintStats 附近。属状态页图表,不在本次范围。
This commit is contained in:
JianFeeeee
2026-10-02 11:16:15 +08:00
parent 30064696b3
commit fbdf0dea10
5 changed files with 632 additions and 20 deletions

View File

@ -1020,9 +1020,17 @@ func (g *Gateway) fireEnd(rec *Req) {
"completion_tokens": rec.Compl,
"cache_hit_tokens": rec.CacheHit,
"cache_miss_tokens": rec.CacheMiss,
"image_count": rec.ImageCount,
"error": rec.Err,
"time": rec.Time,
// Whether UPSTREAM reported a cache number at all. A plugin cannot
// infer this from cache_hit_tokens alone: zero hits because nothing was
// cached and zero hits because the provider never reports caching are
// the same value, and they mean opposite things when you are checking
// whether a cache discount is doing anything. The audit record already
// carried this (rec.CacheReported); the plugin payload did not, so any
// plugin-level cache accounting had to guess.
"cache_reported": rec.CacheReported,
"image_count": rec.ImageCount,
"error": rec.Err,
"time": rec.Time,
// chain_walk: the AUTO tier-by-tier trace, when the request went
// through the chain. Empty for a direct request and for a gateway with
// no plugins loaded. Absent rather than empty so a plugin can tell

View File

@ -5119,6 +5119,95 @@
},
};
// pluginIconHTML renders a plugin-declared sidebar icon.
//
// Text icons are escaped as before. An icon that looks like markup is
// accepted ONLY as a sanitized inline <svg>: a fixed tag allowlist, no
// <script>, no event handlers, no external references. Plugins are
// third-party code running in the operator's browser, so "trust the
// plugin" is not a posture this can take — but neither can it refuse SVG
// outright, because that is the only way an icon matches the native tabs.
function pluginIconHTML(icon) {
var raw = icon == null ? "" : String(icon);
if (!raw) return '<span style="font-size:18px;line-height:1">\u2022</span>';
if (!/<[a-zA-Z!/]/.test(raw)) {
// Plain text (an emoji or a glyph).
return '<span style="font-size:18px;line-height:1">' + esc(raw) + "</span>";
}
var cleaned = sanitizePluginSVG(raw);
if (!cleaned) {
// Markup that is not an acceptable SVG: fall back to a neutral dot
// rather than injecting it or showing raw tags.
return '<span style="font-size:18px:line-height:1">\u2022</span>';
}
return (
'<span style="font-size:18px;line-height:1;display:inline-flex">' +
cleaned +
"</span>"
);
}
// sanitizePluginSVG keeps only what an icon needs.
//
// Allowlist, not a denylist: anything not named here is dropped, so a new
// dangerous construct cannot slip through by default. Attributes are
// limited to geometry and paint (no href/src, no on*, no style with url()).
var SVG_OK_TAGS = { svg: 1, path: 1, circle: 1, rect: 1, line: 1, polyline: 1, polygon: 1, g: 1 };
var SVG_OK_ATTRS = {
viewBox: 1, fill: 1, stroke: 1, "stroke-width": 1, "stroke-linecap": 1,
"stroke-linejoin": 1, d: 1, cx: 1, cy: 1, r: 1, x: 1, y: 1, rx: 1, ry: 1,
x1: 1, y1: 1, x2: 1, y2: 1, points: 1, width: 1, height: 1, opacity: 1,
};
function sanitizePluginSVG(raw) {
var doc = new DOMParser().parseFromString("<svg>" + raw + "</svg>", "image/svg+xml");
var svg = doc.documentElement;
if (!svg || svg.nodeName.toLowerCase() !== "svg" || doc.querySelector("parsererror")) {
return "";
}
// The wrapper we build is the only <svg> we emit. A plugin's own <svg>
// is unwrapped, otherwise the output nests an empty <svg></svg> inside
// ours — visible in the markup, and it also meant the viewBox was read
// from the WRAPPER (which never has one) rather than from the plugin's,
// so any icon declaring a non-default viewBox silently lost it.
var kept = [];
(function walk(node, depth) {
if (depth > 4) return;
for (var i = 0; i < node.children.length; i++) {
var el = node.children[i];
var name = el.nodeName.toLowerCase();
if (name === "svg") {
walk(el, depth + 1); // unwrap, do not emit
continue;
}
if (!SVG_OK_TAGS[name]) continue;
var attrs = "";
for (var a = 0; a < el.attributes.length; a++) {
var at = el.attributes[a];
var an = at.name.toLowerCase();
// Reject anything that can fetch or execute, whatever it is called.
if (/^on/.test(an) || /href|src|xlink|formaction|style/.test(an)) continue;
if (!SVG_OK_ATTRS[an]) continue;
var val = String(at.value).replace(/[<>"'&]/g, "");
attrs += " " + an + '="' + val + '"';
}
kept.push("<" + name + attrs + "></" + name + ">");
walk(el, depth + 1);
}
})(svg, 0);
if (!kept.length) return "";
// Prefer the plugin's own viewBox; fall back to the 24px grid every
// native icon uses.
var innerSvg = svg.querySelector("svg");
var vb = (innerSvg && innerSvg.getAttribute("viewBox")) || svg.getAttribute("viewBox") || "0 0 24 24";
return (
'<svg viewBox="' + vb.replace(/[^\d\s.\-]/g, "") + '" fill="none" ' +
'stroke="currentColor" stroke-width="2" stroke-linecap="round" ' +
'stroke-linejoin="round" style="width:18px;height:18px">' +
kept.join("") +
"</svg>"
);
}
// remountPluginElements re-attaches plugin elements after a host page
// rebuilt its DOM. Safe to call at any time: each mount is a no-op when
// the wrapper is already present in the current build of the pane, so a
@ -5173,10 +5262,16 @@
btn.className = "sb-i";
btn.dataset.tab = id;
btn.title = ui.page.title || id;
btn.innerHTML =
'<span style="font-size:18px;line-height:1">' +
esc(ui.page.icon || "•") +
"</span>";
// A plugin icon may be plain text (an emoji, a glyph) or an inline
// SVG snippet. Native tabs use inline SVG styled with
// `stroke: currentColor`, so an emoji next to them renders at the
// wrong size and ignores the theme — that is what "the icon looks
// wrong" meant.
//
// The SVG form is allowed through RAW, which is only safe because
// it is strictly filtered: see pluginIconHTML. Escaping it (as this
// did) would print the markup as text instead.
btn.innerHTML = pluginIconHTML(ui.page.icon);
btn.onclick = () => goTab(id);
nav.appendChild(btn);
PLUGIN_PAGES.add(id);