fix(scheduler): AUTO 遇空内容响应降级到下一个 slot(客户端曾报 "no content")

生产故障:pi 客户端报 `model "AUTO" returned a completed response with no content`,
重试延迟 8 秒。实测 AUTO 20 次有 2 次返回空 content,全部是 claude-opus-4-8。

根因(直连上游抓包确认):思考型模型先吐 reasoning_content,max_tokens 小到
思考阶段就把预算用完时,上游返回 200 / finish_reason=length,28 个 chunk 全是
reasoning_content、content 一片空白。runTier 只看 err == nil 就当成功返回,
客户端拿到一个空响应。

修复:
- resultIsEmpty:非流式路径把「无 content、无 tool_calls、无 image」的响应当作
  slot 失败继续降级。注意 ReasoningContent 不算内容——客户端要的是文本,为
  另一个模型的思考阶段扣住请求比降级更糟。
- peekStream:流式路径在出现首个真实内容前缓冲 reasoning 前导,流结束仍无内容
  则回报空结果,让 chainDrive 换 slot。缓冲只覆盖思考前导,拿到内容后立即
  转发。tool_calls delta 算内容,agent 回合不会被误判。
- 3 条判据 + 3 个变异(恒 false / 恒 true / reasoning 算内容)全部被捕获。

同时修三个 WebUI 布局缺陷(都靠截图而非 DOM 断言发现):
- 插件侧栏项只渲染图标没有标题:btn.innerHTML 只塞 pluginIconHTML(pg.icon),
  与原生页的「图标 + <span>标题</span>」不一致,侧栏是一排无名图标。
- 计费维度表 8 列挤在 465px 卡片里:table-layout:fixed 把每列压到 62px,
  23/80 个单元格溢出、数字互相重叠。改为 6 列(token 细分合并为
  「输入(新鲜+缓存)」,细分进 title)+ table-layout:auto,实测 0/60 溢出。
- 数字列 word-break:break-all 让每个字符独占一行(USD 0.56 竖排成 U/S/D),
  改 nowrap + 容器横向滚动。
This commit is contained in:
JianFeeeee
2026-10-02 15:10:01 +08:00
parent 09cb215208
commit c19b8e6394
8 changed files with 592 additions and 199 deletions

View File

@ -510,6 +510,22 @@ 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">
<button id="billing-view-usage-btn" class="ghost small" onclick="window.__billing_view('usage')">usage</button>
<button id="billing-view-rules-btn" class="ghost small" onclick="window.__billing_view('rules')">rules</button>
<span class="grow"></span>
<span id="billing-view-msg" class="muted" style="font-size:12px"></span>
</div>
<!-- 两个视图容器都要 flex:1;width:100%。
宿主的 .tab-pane 是 display:flex 列容器,子元素默认按内容宽度收缩——不写
flex:1 时 rules 视图被压成 72px,里面的表格宽度 0,整页看着像没渲染。
这是我合并两页时踩的坑:旧的两个页面各自是独立的 pane,不存在这个问题。 -->
<div id="billing-view-usage" style="flex:1;min-width:0;width:100%">
<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)">
@ -518,7 +534,10 @@ plugin.ui = {
</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">
<!-- 维度表每行 8 列(名称+开销+请求+输入+新鲜+缓存+缓存%+输出),下限必须
>=340px:我在 260px 时把数字排成竖着的单字符列(USD 0.56 → "U/S/D"),
填满了宽度却没法看。2 列 × 340px 刚好排满 946px,第三张(按天)横跨整行。 -->
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:16px">
<div class="card" style="padding:14px">
<h3 id="billing-h-src" style="margin:0 0 10px;font-size:14px"></h3>
<div id="billing-by-source"></div>
@ -532,10 +551,17 @@ plugin.ui = {
<div id="billing-by-key"></div>
</div>
</div>
<div class="card" style="padding:14px;margin-top:16px">
<!-- 按天表横跨整行:网格里 4 张卡是 2×2,按天是最后一张,落在左下角,
右边空一块。grid-column:1/-1 让它单独占满一行,宽度也够放下日期列。 -->
<div class="card" style="padding:14px;margin-top:16px;grid-column:1/-1">
<h3 id="billing-h-day" style="margin:0 0 10px;font-size:14px"></h3>
<div id="billing-by-day"></div>
</div>
</div>
<div id="billing-view-rules" style="display:none;flex:1;min-width:0;width:100%">
<div id="br-body"><div class="muted">…</div></div>
</div>
</div>
<script>
(function () {
@ -556,6 +582,12 @@ plugin.ui = {
thName: "name", thCost: "cost", thReqs: "reqs", thPrompt: "prompt",
thFresh: "fresh", thCache: "cache", thCachePct: "cache%",
thCompletion: "completion",
// 8 columns do not fit the 465px a two-up grid gives each card: measured
// 23 of 80 cells overflowing with table-layout:fixed. The four token
// sub-columns (prompt / fresh / cache / cache%) collapse into ONE column
// "in (fresh+cache)" with the breakdown in its tooltip, so the numbers a
// reader actually compares stay on screen.
thInSplit: "in (fresh+cache)",
noData: "no data yet",
periodLabel: "Period", periodDay: "Today", periodWeek: "This week",
periodMonth: "This month", periodAll: "All time",
@ -571,6 +603,7 @@ plugin.ui = {
thName: "名称", thCost: "开销", thReqs: "请求", thPrompt: "输入",
thFresh: "新鲜", thCache: "缓存", thCachePct: "缓存%",
thCompletion: "输出",
thInSplit: "输入(新鲜+缓存)",
noData: "暂无数据",
periodLabel: "统计周期", periodDay: "今日", periodWeek: "本周",
periodMonth: "本月", periodAll: "全部",
@ -712,33 +745,44 @@ plugin.ui = {
}
function row(name, b, cur) {
var fresh = (b.cache_fresh_tokens === undefined) ? (b.prompt_tokens || 0) : b.cache_fresh_tokens;
// 名称列 ellipsis(title 悬停看全名);数字列 break-all 在列宽内换行而不是
// 把表格撑出卡片。单元格结构与列数不变,列数判据不受影响。
return "<tr><td style='overflow:hidden'><b style='display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis' title='" +
// 名称列 ellipsis(title 悬停看全名);数字列不折行。
//
// 之前数字列用 word-break:break-all “避免撑破卡片”,实测后果是每个数字
// 字符独占一行(USD 0.56 变成 “U/S/D” 竖着排),整张表没法读。列宽不足
// 时正确的做法是让表格横向滚动(外层已有 overflow-x:auto),而不是把
// 数字椎成竖排。th 同理:表头也参与同一套列宽。
var detail = "prompt " + fmtInt(b.prompt_tokens || 0) +
" / fresh " + fmtInt(fresh) +
" / cache " + fmtInt(b.cache_hit_tokens || 0) +
" / rate " + cacheRate(b);
return "<tr><td style='max-width:150px;overflow:hidden'><b style='display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis' title='" +
esc(String(name).replace(/'/g, "&#39;")) + "'>" + esc(name) + "</b></td>" +
"<td style='word-break:break-all'>" + money(b.cost, cur) + "</td>" +
"<td style='word-break:break-all'>" + fmtInt(b.requests || 0) + "</td>" +
"<td style='word-break:break-all'>" + fmtInt(b.prompt_tokens || 0) + "</td>" +
"<td style='word-break:break-all'>" + fmtInt(fresh) + "</td>" +
"<td style='word-break:break-all'>" + fmtInt(b.cache_hit_tokens || 0) + "</td>" +
"<td style='word-break:break-all'>" + esc(cacheRate(b)) + "</td>" +
"<td style='word-break:break-all'>" + fmtInt(b.completion_tokens || 0) + "</td></tr>";
"<td class='bnum'>" + money(b.cost, cur) + "</td>" +
"<td class='bnum'>" + fmtInt(b.requests || 0) + "</td>" +
"<td class='bnum' title='" + esc(detail) + "'>" + fmtInt(b.prompt_tokens || 0) + "</td>" +
"<td class='bnum' title='" + esc(cacheRate(b)) + "'>" + esc(cacheRate(b)) + "</td>" +
"<td class='bnum'>" + fmtInt(b.completion_tokens || 0) + "</td></tr>";
}
function tableFor(el, obj, cur, empty) {
var keys = Object.keys(obj || {});
if (!keys.length) { el.innerHTML = '<div class="muted">' + empty + "</div>"; return; }
keys.sort(function (a, b) { return (obj[b].cost || 0) - (obj[a].cost || 0); });
var TH = L();
var h = "<table style='width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;word-break:break-word'>" +
// table-layout:auto, not fixed: fixed divided the 465px card into 62px
// columns and the numbers overlapped each other. auto lets the browser size
// columns by content, and min-width keeps the table readable by scrolling
// sideways instead of crushing every cell.
var h = "<style>.bnum{white-space:nowrap;font-variant-numeric:tabular-nums}</style>" +
"<div style='overflow-x:auto'>" +
"<table style='width:100%;border-collapse:collapse;font-size:13px;min-width:400px'>" +
"<tr style='text-align:left;opacity:.65'><th>" + TH.thName + "</th><th>" + TH.thCost +
"</th><th>" + TH.thReqs + "</th><th>" + TH.thPrompt +
"</th><th>" + TH.thFresh + "</th><th>" + TH.thCache + "</th><th>" + TH.thCachePct +
"</th><th>" + TH.thCompletion + "</th></tr>";
"</th><th>" + TH.thReqs + "</th><th>" + TH.thInSplit +
"</th><th>" + TH.thCachePct + "</th><th>" + TH.thCompletion + "</th></tr>";
for (var i = 0; i < keys.length; i++) {
var k = keys[i];
h += "<tr style='border-top:1px solid rgba(120,90,150,.14)'>" + row(k, obj[k], cur) + "</tr>";
}
el.innerHTML = h + "</table>";
el.innerHTML = h + "</table></div>";
}
function renderTitles() {
var T = L();
@ -820,6 +864,39 @@ plugin.ui = {
renderTitles();
refresh();
};
// ---- 视图切换(用量 / 规则)----
//
// 计费是一个整体:价格规则决定数字,数字是价格规则的结果。拆成两个侧栏页
// 时改完价格要翻到另一个页面才知道有没有生效,等于把一个闭环切断。
//
// 两页共用同一个侧栏入口,但规则编辑器改价后会立即重拉统计(rules 页的
// save() 末尾就调了 __billingRefresh),切回来就能看到变化。
var VIEWS = ["usage", "rules"];
function viewLabel(v) {
var zh = { usage: "用量", rules: "计费规则" };
var en = { usage: "Usage", rules: "Price rules" };
var lang = (window.pluginAPI && pluginAPI.lang) === "en" ? "en" : "zh";
return (lang === "en" ? en : zh)[v] || v;
}
function paintView() {
VIEWS.forEach(function (v) {
var pane = document.getElementById("billing-view-" + v);
if (pane) pane.style.display = v === VIEW ? "" : "none";
var btn = document.getElementById("billing-view-" + v + "-btn");
// The active button is filled, the other ghosted — the host's own
// segmented control uses the same convention, so it reads as native.
if (btn) btn.className = (v === VIEW ? "" : "ghost ") + "small";
if (btn) btn.textContent = viewLabel(v);
});
}
var VIEW = "usage";
window.__billing_view = function (v) {
if (VIEWS.indexOf(v) < 0) return;
VIEW = v;
paintView();
if (v === "rules" && window.__billingRulesLoad) window.__billingRulesLoad();
};
paintView();
// 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
@ -836,107 +913,13 @@ plugin.ui = {
if (pluginAPI.onLangChange) {
pluginAPI.onLangChange(function () {
renderTitles();
paintView();
refresh();
});
}
}
})();
</script>
]==],
},
-- Two elements on the EXISTING status page: a headline tile and a
-- per-source cost breakdown, so the number is visible without opening the
-- Billing tab.
elements = {
{
target = "status",
anchor = "top",
order = 5,
mount = [==[
<div class="card" id="billing-status-tile" style="padding:12px;margin-bottom:12px">
<div style="font-size:11px;opacity:.65" id="billing-tile-label"></div>
<div id="billing-status-total" style="font-size:22px;font-weight:600;margin-top:4px">—</div>
<div id="billing-status-sub" style="font-size:12px;opacity:.65;margin-top:2px"></div>
</div>
<script>
(function () {
function fmt(n) {
n = Number(n || 0);
if (n === 0) return "0";
if (Math.abs(n) < 0.000001) return n.toExponential(2);
return n.toFixed(Math.abs(n) < 1 ? 6 : 4);
}
async function tick() {
try {
var r = await fetch("/api/plugins/billing/state", { credentials: "same-origin" });
if (!r.ok) return;
var j = await r.json();
var st = j.state;
if (!st || !st.total) return;
var cur = st.currency || "USD";
// The await above yields, so the host page may have rebuilt or torn down
// this element in the meantime — and it does: renderStatus assigns
// pane.innerHTML wholesale on every refresh. Assigning to a null element
// threw a TypeError that the surrounding catch logged on every repaint.
// Re-check after every await rather than assuming the DOM survived it.
var totalEl = document.getElementById("billing-status-total");
if (!totalEl) return;
totalEl.textContent = cur + " " + fmt(st.total.cost);
// The tile's label is plugin UI text, so it follows the host language via
// the same pluginAPI surface the Billing page uses.
var lab = document.getElementById("billing-tile-label");
if (lab) {
var lang = (window.pluginAPI && pluginAPI.lang) || "zh";
lab.textContent = lang === "zh" ? "总开销(billing 插件)" : "Total spend (billing plugin)";
}
var parts = [];
var srcs = st.by_source || {};
var names = Object.keys(srcs).sort(function (a, b) {
return (srcs[b].cost || 0) - (srcs[a].cost || 0);
});
for (var i = 0; i < Math.min(3, names.length); i++) {
parts.push(names[i] + " " + fmt(srcs[names[i]].cost));
}
var sub2 = document.getElementById("billing-status-sub");
if (sub2) sub2.textContent =
(st.total.requests || 0) + " requests" + (parts.length ? " · top: " + parts.join(" · ") : "");
} catch (e) {
// Same reasoning as the Billing page: decoration must never break the
// host page, but a silent catch turns a broken widget into "the plugin
// just doesn't show anything" with no way to tell why.
if (window.console && console.error) console.error("[billing] status tile refresh failed", e);
}
}
if (window.pluginAPI && pluginAPI.onTabShown) pluginAPI.onTabShown(tick);
tick();
})();
</script>
]==],
},
},
}
-- 第二页:计费规则编辑。
--
-- 与统计页分开,而不是塞进同一页的标签里:规则是可编辑的配置,统计是只读的
-- 结果,混在一个页面里会让"改数字"和"看数字"的边界变模糊。
-- pages is built by APPENDING. Writing plugin.ui.pages[2] instead makes the
-- table sparse (index 2 with no 1, 2), and Lua's tojson/JSON conversion then
-- emits an OBJECT {"2": {...}} instead of an array — which the Go side decodes
-- to nothing at all. The plugin then loaded with UI == nil and no error
-- anywhere: the totals page, the status tile and this editor all silently
-- vanished. First-wins on page_id is enforced Go-side, so appending is safe.
plugin.ui.pages = plugin.ui.pages or {}
table.insert(plugin.ui.pages, {
page_id = "billing-rules",
title = "Billing rules",
icon = [==[<svg viewBox="0 0 24 24"><path d="M4 7h10M18 7h2M4 12h4M12 12h8M4 17h8M16 17h4"/><path d="M14 5l2 2-2 2M10 10l-2 2 2 2M12 15l2 2-2 2"/></svg>]==],
order = 41,
mount = [==[
<div id="br-root" style="padding:16px;min-width:0;max-width:100%">
<div id="br-body"><div class="muted">…</div></div>
</div>
<script>
(function () {
var API = "/api/plugins/billing/rules";
var STR = {
@ -1077,20 +1060,24 @@ table.insert(plugin.ui.pages, {
return "<option value='" + o[0] + "'" + (rule.mode === o[0] ? " selected" : "") + ">" + esc(o[1]) + "</option>";
}).join("");
var p = rule.peak;
// 每个单元格的内容都必须 box-sizing:border-box + min-width:0,否则 input
// 的固有宽度会把列撑开:实测表格 986px 挤在 954px 容器里,最右侧的
// 「删除」列被切掉 32px,按钮点不到;r-cur/r-sub 这些只有 style='width:74px'
// 的输入框实测只有 40px 可用,几乎不能输入。
return "<tr class='br-rule' data-url='" + esc(rule.url || "") + "'>" +
"<td><input class='r-url' value='" + esc(rule.url || "") + "' list='br-urls' style='width:100%'>" +
"<td style='min-width:200px'><input class='r-url' value='" + esc(rule.url || "") + "' list='br-urls' style='width:100%;box-sizing:border-box'>" +
(unknown ? "<div style='color:#e0a33e;font-size:11px'>&#9888; " + esc(T.warnNoSource) + "</div>" : "") +
"</td>" +
"<td><select class='r-mode'>" + opts + "</select></td>" +
"<td style='width:74px'><input class='r-cur' value='" + esc(rule.currency || "") + "' placeholder='USD'></td>" +
"<td style='width:92px'><input class='r-sub' type='number' step='0.01' value='" +
(rule.subscription ? esc(rule.subscription) : "") + "' placeholder='" + esc(T.monthly) + "'></td>" +
"<td style='width:64px'><input class='r-mult' type='number' step='0.1' value='" +
(p && p.multiplier ? esc(p.multiplier) : "") + "' placeholder='" + esc(T.peakMult) + "'></td>" +
"<td style='width:104px'><input class='r-hours' value='" + esc(hoursText(p)) + "' placeholder='1-4,6-10'></td>" +
"<td style='width:74px'><input class='r-days' value='" + esc(daysText(p)) + "' placeholder='1,2,3'></td>" +
"<td style='min-width:210px'>" + modelBlock(rule.models, T) + "</td>" +
"<td><button class='ghost small r-del'>" + esc(T.delRule) + "</button></td></tr>";
"<td style='width:78px'><select class='r-mode' style='width:100%;box-sizing:border-box'>" + opts + "</select></td>" +
"<td style='width:64px'><input class='r-cur' value='" + esc(rule.currency || "") + "' placeholder='USD' style='width:100%;box-sizing:border-box'></td>" +
"<td style='width:76px'><input class='r-sub' type='number' step='0.01' value='" +
(rule.subscription ? esc(rule.subscription) : "") + "' placeholder='" + esc(T.monthly) + "' style='width:100%;box-sizing:border-box'></td>" +
"<td style='width:58px'><input class='r-mult' type='number' step='0.1' value='" +
(p && p.multiplier ? esc(p.multiplier) : "") + "' placeholder='" + esc(T.peakMult) + "' style='width:100%;box-sizing:border-box'></td>" +
"<td style='width:88px'><input class='r-hours' value='" + esc(hoursText(p)) + "' placeholder='1-4,6-10' style='width:100%;box-sizing:border-box'></td>" +
"<td style='width:62px'><input class='r-days' value='" + esc(daysText(p)) + "' placeholder='1,2,3' style='width:100%;box-sizing:border-box'></td>" +
"<td style='min-width:180px'>" + modelBlock(rule.models, T) + "</td>" +
"<td style='width:56px'><button class='ghost small r-del'>" + esc(T.delRule) + "</button></td></tr>";
}
// ---- collecting one rule back out of the DOM ----------------------------
@ -1260,7 +1247,7 @@ table.insert(plugin.ui.pages, {
"<span class='grow'></span>" +
"<button class='ghost small p-add' data-pid='" + esc(p.id) + "'>" + esc(T.addRule) + "</button>" +
"</div>" +
"<div style='overflow-x:auto'><table style='width:100%;border-collapse:collapse;font-size:12px;min-width:900px'>" +
"<div style='overflow-x:auto'><table style='width:100%;border-collapse:collapse;font-size:12px;table-layout:fixed'>" +
"<tr style='text-align:left;opacity:.65'>" +
"<th>" + esc(T.url) + "</th><th>" + esc(T.mode) + "</th><th>" + esc(T.currency) + "</th>" +
"<th>" + esc(T.monthly) + "</th><th>" + esc(T.peakMult) + "</th><th>" + esc(T.peakHours) + "</th>" +
@ -1425,12 +1412,86 @@ table.insert(plugin.ui.pages, {
});
}
// 导出给同页的视图切换:切到 rules 视图时按需拉取(进入页面已经 load 过一次,
// 再次进入不必重拉,除非这期间别人改过价格)。
window.__billingRulesLoad = load;
render();
load();
if (window.pluginAPI && pluginAPI.onTabShown) pluginAPI.onTabShown(load);
})();</script>
]==],
},
-- Two elements on the EXISTING status page: a headline tile and a
-- per-source cost breakdown, so the number is visible without opening the
-- Billing tab.
elements = {
{
target = "status",
anchor = "top",
order = 5,
mount = [==[
<div class="card" id="billing-status-tile" style="padding:12px;margin-bottom:12px">
<div style="font-size:11px;opacity:.65" id="billing-tile-label"></div>
<div id="billing-status-total" style="font-size:22px;font-weight:600;margin-top:4px">—</div>
<div id="billing-status-sub" style="font-size:12px;opacity:.65;margin-top:2px"></div>
</div>
<script>
(function () {
function fmt(n) {
n = Number(n || 0);
if (n === 0) return "0";
if (Math.abs(n) < 0.000001) return n.toExponential(2);
return n.toFixed(Math.abs(n) < 1 ? 6 : 4);
}
async function tick() {
try {
var r = await fetch("/api/plugins/billing/state", { credentials: "same-origin" });
if (!r.ok) return;
var j = await r.json();
var st = j.state;
if (!st || !st.total) return;
var cur = st.currency || "USD";
// The await above yields, so the host page may have rebuilt or torn down
// this element in the meantime — and it does: renderStatus assigns
// pane.innerHTML wholesale on every refresh. Assigning to a null element
// threw a TypeError that the surrounding catch logged on every repaint.
// Re-check after every await rather than assuming the DOM survived it.
var totalEl = document.getElementById("billing-status-total");
if (!totalEl) return;
totalEl.textContent = cur + " " + fmt(st.total.cost);
// The tile's label is plugin UI text, so it follows the host language via
// the same pluginAPI surface the Billing page uses.
var lab = document.getElementById("billing-tile-label");
if (lab) {
var lang = (window.pluginAPI && pluginAPI.lang) || "zh";
lab.textContent = lang === "zh" ? "总开销(billing 插件)" : "Total spend (billing plugin)";
}
var parts = [];
var srcs = st.by_source || {};
var names = Object.keys(srcs).sort(function (a, b) {
return (srcs[b].cost || 0) - (srcs[a].cost || 0);
});
for (var i = 0; i < Math.min(3, names.length); i++) {
parts.push(names[i] + " " + fmt(srcs[names[i]].cost));
}
var sub2 = document.getElementById("billing-status-sub");
if (sub2) sub2.textContent =
(st.total.requests || 0) + " requests" + (parts.length ? " · top: " + parts.join(" · ") : "");
} catch (e) {
// Same reasoning as the Billing page: decoration must never break the
// host page, but a silent catch turns a broken widget into "the plugin
// just doesn't show anything" with no way to tell why.
if (window.console && console.error) console.error("[billing] status tile refresh failed", e);
}
}
if (window.pluginAPI && pluginAPI.onTabShown) pluginAPI.onTabShown(tick);
tick();
})();
</script>
]==],
})
},
},
}
return plugin