mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
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:
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
Reference in New Issue
Block a user