mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
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:
@ -5403,7 +5403,18 @@
|
|||||||
// The SVG form is allowed through RAW, which is only safe because
|
// The SVG form is allowed through RAW, which is only safe because
|
||||||
// it is strictly filtered: see pluginIconHTML. Escaping it (as this
|
// it is strictly filtered: see pluginIconHTML. Escaping it (as this
|
||||||
// did) would print the markup as text instead.
|
// did) would print the markup as text instead.
|
||||||
btn.innerHTML = pluginIconHTML(pg.icon);
|
//
|
||||||
|
// The label is NOT optional. Every native tab is
|
||||||
|
// `<svg>…</svg><span data-i="navX">标题</span>`; a plugin tab that
|
||||||
|
// carried only the icon rendered as a nameless icon in the sidebar,
|
||||||
|
// which is what "the navigation entry has no title" was. The span
|
||||||
|
// carries no data-i because plugin titles are not in the host's
|
||||||
|
// translation table — set from pg.title, same as btn.title.
|
||||||
|
btn.innerHTML =
|
||||||
|
pluginIconHTML(pg.icon) +
|
||||||
|
'<span>' +
|
||||||
|
esc(pg.title || id) +
|
||||||
|
"</span>";
|
||||||
btn.onclick = () => goTab(id);
|
btn.onclick = () => goTab(id);
|
||||||
nav.appendChild(btn);
|
nav.appendChild(btn);
|
||||||
PLUGIN_PAGES.add(id);
|
PLUGIN_PAGES.add(id);
|
||||||
|
|||||||
@ -366,3 +366,25 @@ func TestPluginPagesAreReachableByGoTab(t *testing.T) {
|
|||||||
"declaration must come first", decl, goTab)
|
"declaration must come first", decl, goTab)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPluginSidebarEntryCarriesATitle: a plugin tab must render icon AND label.
|
||||||
|
//
|
||||||
|
// Native tabs are `<svg>…</svg><span data-i="navStatus">状态</span>`; the plugin
|
||||||
|
// branch set innerHTML to the icon alone, so every plugin page showed up as a
|
||||||
|
// nameless icon in the sidebar. An operator cannot tell what an icon means
|
||||||
|
// until they click it.
|
||||||
|
func TestPluginSidebarEntryCarriesATitle(t *testing.T) {
|
||||||
|
html := uiSource(t)
|
||||||
|
anchor := "btn.innerHTML =\n pluginIconHTML(pg.icon) +"
|
||||||
|
i := strings.Index(html, anchor)
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatal("plugin sidebar button construction not found")
|
||||||
|
}
|
||||||
|
// Look at the statement the anchor opens, not the whole file.
|
||||||
|
stmt := html[i : i+400]
|
||||||
|
if strings.Contains(stmt, "esc(pg.title") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Errorf("plugin sidebar button renders the icon only; a label is required. "+
|
||||||
|
"got: %.200s", stmt)
|
||||||
|
}
|
||||||
|
|||||||
@ -275,21 +275,14 @@ func TestBillingPluginDeclaresUI(t *testing.T) {
|
|||||||
if page, _ := ui["page"].(string); page != "billing" {
|
if page, _ := ui["page"].(string); page != "billing" {
|
||||||
t.Errorf("ui.page = %v, want \"billing\"", ui["page"])
|
t.Errorf("ui.page = %v, want \"billing\"", ui["page"])
|
||||||
}
|
}
|
||||||
// The management listing must name EVERY page, not just the first.
|
// ONE page, not two. The rule editor and the totals share a sidebar
|
||||||
// Without `pages`, a plugin contributing the totals page and the rule
|
// entry because billing is one thing: prices decide the numbers and the
|
||||||
// editor is shown as contributing one, and the operator has no way to
|
// numbers are the result of the prices. Split across two pages, saving a
|
||||||
// tell from the plugin list that a second screen exists.
|
// price left no on-screen way to see its effect — the loop the operator
|
||||||
// List() builds this map in Go, so the value is []string here; only
|
// actually works in was cut in half.
|
||||||
// after the HTTP round-trip would it be []interface{}. Asserting the
|
|
||||||
// wrong one is a silently empty set, which is what made the first
|
|
||||||
// version of this check fail for the wrong reason.
|
|
||||||
pages, _ := ui["pages"].([]string)
|
pages, _ := ui["pages"].([]string)
|
||||||
found := map[string]bool{}
|
if len(pages) != 1 || pages[0] != "billing" {
|
||||||
for _, id := range pages {
|
t.Errorf("ui.pages = %v, want exactly one page [billing]", pages)
|
||||||
found[id] = true
|
|
||||||
}
|
|
||||||
if !found["billing-rules"] {
|
|
||||||
t.Errorf("ui.pages = %v, want it to include billing-rules", found)
|
|
||||||
}
|
}
|
||||||
if n, _ := ui["elements"].(int); n < 1 {
|
if n, _ := ui["elements"].(int); n < 1 {
|
||||||
t.Error("billing contributes no element to an existing page")
|
t.Error("billing contributes no element to an existing page")
|
||||||
@ -299,52 +292,84 @@ func TestBillingPluginDeclaresUI(t *testing.T) {
|
|||||||
t.Fatal("billing plugin is not loaded")
|
t.Fatal("billing plugin is not loaded")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestBillingDeclaresRulesEditorPage: the rule editor is a SECOND screen, not a
|
// TestBillingRuleEditorLivesInsideTheBillingPage: the rule editor is a VIEW
|
||||||
// tab inside the totals page. Two reasons it has to be its own page: mixing
|
// inside the billing page, not a second sidebar entry.
|
||||||
// editable configuration with read-only results blurs the line between "looking
|
//
|
||||||
// at numbers" and "changing prices", and the plugin UI contract only ever had
|
// This started as a separate page and was wrong: saving a price sent the
|
||||||
// room for one page — a second plugin contributing a page silently overwrote
|
// operator to a different screen to find out whether it worked. A single page
|
||||||
// the first, so multi-page had to become a first-class shape before this could
|
// with a view switch keeps the loop closed, and the rule editor already refreshes
|
||||||
// exist.
|
// the usage numbers on save, so the effect is visible immediately.
|
||||||
func TestBillingDeclaresRulesEditorPage(t *testing.T) {
|
//
|
||||||
|
// The mount must therefore carry BOTH the usage tables and the rule editor, and
|
||||||
|
// the switch that toggles between them.
|
||||||
|
func TestBillingRuleEditorLivesInsideTheBillingPage(t *testing.T) {
|
||||||
ps, _ := billingVM(t)
|
ps, _ := billingVM(t)
|
||||||
var ui *UIExtension = nil
|
var ui *UIExtension
|
||||||
for _, p := range ps.plugins {
|
for _, p := range ps.plugins {
|
||||||
if p.Info.Name == "billing" {
|
if p.Info.Name == "billing" {
|
||||||
ui = p.UI
|
ui = p.UI
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ui == nil {
|
if ui == nil || ui.Page == nil {
|
||||||
t.Fatal("billing plugin loaded with no UI extension")
|
t.Fatal("billing plugin loaded with no page")
|
||||||
}
|
}
|
||||||
// Read the plugin's OWN extension, which is where the single `page` field
|
if ui.Page.PageID != "billing" {
|
||||||
// still lives — the fold into one list happens in the merged view, not here.
|
t.Errorf("page id = %q, want billing", ui.Page.PageID)
|
||||||
ids := map[string]bool{}
|
|
||||||
if ui.Page != nil {
|
|
||||||
ids[ui.Page.PageID] = true
|
|
||||||
}
|
}
|
||||||
for _, pg := range ui.Pages {
|
mount := ui.Page.Mount
|
||||||
if pg != nil {
|
|
||||||
ids[pg.PageID] = true
|
// Both halves on one page.
|
||||||
}
|
for _, needle := range []string{`id="billing-kpis"`, `id="billing-by-source"`} {
|
||||||
}
|
|
||||||
if !ids["billing"] {
|
|
||||||
t.Error("the totals page is missing")
|
|
||||||
}
|
|
||||||
if !ids["billing-rules"] {
|
|
||||||
t.Errorf("the rule editor page is missing; pages = %v", ids)
|
|
||||||
}
|
|
||||||
// The editor must actually contain its controls, not just a pane: a page
|
|
||||||
// that mounts an empty div looks fine in the sidebar and does nothing.
|
|
||||||
var mount string
|
|
||||||
for _, pg := range ui.Pages {
|
|
||||||
if pg != nil && pg.PageID == "billing-rules" {
|
|
||||||
mount = pg.Mount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, needle := range []string{`id="br-body"`, "data-act='save'", "data-act='export'", "data-act='newprofile'", ".r-url", ".r-mode"} {
|
|
||||||
if !strings.Contains(mount, needle) {
|
if !strings.Contains(mount, needle) {
|
||||||
t.Errorf("the rule editor page is missing %s", needle)
|
t.Errorf("the usage half is missing %s", needle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, needle := range []string{
|
||||||
|
`id="br-body"`,
|
||||||
|
"data-act='save'", "data-act='export'", "data-act='newprofile'",
|
||||||
|
".r-url", ".r-mode",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(mount, needle) {
|
||||||
|
t.Errorf("the rule editor is missing %s", needle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The switch itself, or the two halves are both on screen at once.
|
||||||
|
for _, needle := range []string{`id="billing-view-usage"`, `id="billing-view-rules"`, `window.__billing_view(`} {
|
||||||
|
if !strings.Contains(mount, needle) {
|
||||||
|
t.Errorf("the view switch is missing %s", needle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The rules view must start hidden, otherwise it renders under the usage
|
||||||
|
// tables and the page is a wall of two editors stacked.
|
||||||
|
if !strings.Contains(mount, `id="billing-view-rules" style="display:none;`) {
|
||||||
|
t.Error(`the rules view does not start hidden — both halves would render at once`)
|
||||||
|
}
|
||||||
|
// NO DUPLICATE IDS between the switch buttons and the view containers.
|
||||||
|
// They shipped as billing-view-<name> on BOTH the button and the pane, so
|
||||||
|
// document.getElementById returned the 72px-wide BUTTON for the pane and
|
||||||
|
// every measurement was of the wrong element: the table measured 0 wide and
|
||||||
|
// the page looked broken while the layout was fine. The ids must be unique
|
||||||
|
// and the buttons carry a distinct suffix.
|
||||||
|
for _, id := range []string{`billing-view-usage`, `billing-view-rules`} {
|
||||||
|
if n := strings.Count(mount, `id="`+id+`"`); n != 1 {
|
||||||
|
t.Errorf("id %q appears %d times; getElementById would return the wrong element", id, n)
|
||||||
|
}
|
||||||
|
if !strings.Contains(mount, `id="`+id+`-btn"`) {
|
||||||
|
t.Errorf("switch button for %q is missing its -btn id", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Both view containers must FILL the pane. The host's tab-pane is a flex
|
||||||
|
// column, so a plain div shrinks to its content: without flex:1/width:100%
|
||||||
|
// the rules view measured 72px wide and its table measured 0 — the page
|
||||||
|
// rendered "nothing" while the DOM was perfectly correct. This is exactly
|
||||||
|
// the class of bug a DOM assertion misses and a screenshot catches.
|
||||||
|
for _, needle := range []string{
|
||||||
|
`id="billing-view-usage" style="flex:1`,
|
||||||
|
`id="billing-view-rules" style="display:none;flex:1`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(mount, needle) {
|
||||||
|
t.Errorf("view container does not fill the pane: %q missing — "+
|
||||||
|
"a flex item without flex:1 collapses to content width", needle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -387,7 +412,10 @@ return plugin`)
|
|||||||
for _, pg := range ui.Pages {
|
for _, pg := range ui.Pages {
|
||||||
got[pg.PageID] = true
|
got[pg.PageID] = true
|
||||||
}
|
}
|
||||||
for _, want := range []string{"billing", "billing-rules", "other-page", "third-a", "third-b"} {
|
// billing contributes exactly one page (the rule editor is a view inside it),
|
||||||
|
// so it is listed once. The multi-page merging is exercised by the three
|
||||||
|
// plugins added here.
|
||||||
|
for _, want := range []string{"billing", "other-page", "third-a", "third-b"} {
|
||||||
if !got[want] {
|
if !got[want] {
|
||||||
t.Errorf("merged UI lost page %q; has %v", want, got)
|
t.Errorf("merged UI lost page %q; has %v", want, got)
|
||||||
}
|
}
|
||||||
@ -419,8 +447,8 @@ return plugin`)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Order must be honoured so the sidebar is predictable.
|
// Order must be honoured so the sidebar is predictable.
|
||||||
if len(ui.Pages) != 5 {
|
if len(ui.Pages) != 4 {
|
||||||
t.Fatalf("expected all 5 pages to merge, got %d: %v", len(ui.Pages), got)
|
t.Fatalf("expected all 4 pages to merge, got %d: %v", len(ui.Pages), got)
|
||||||
}
|
}
|
||||||
for i := 1; i < len(ui.Pages); i++ {
|
for i := 1; i < len(ui.Pages); i++ {
|
||||||
if ui.Pages[i].Order < ui.Pages[i-1].Order {
|
if ui.Pages[i].Order < ui.Pages[i-1].Order {
|
||||||
|
|||||||
@ -309,8 +309,20 @@ func TestBillingTableHeaderMatchesRowColumns(t *testing.T) {
|
|||||||
if hStart < 0 {
|
if hStart < 0 {
|
||||||
t.Fatal("no tableFor in the billing page script")
|
t.Fatal("no tableFor in the billing page script")
|
||||||
}
|
}
|
||||||
|
// Count the usage table's headers ONLY. The count is taken from tableFor to
|
||||||
|
// the end of the script, which used to be fine when the rule editor lived on
|
||||||
|
// its own page; now that both halves share one script, the rule table's nine
|
||||||
|
// headers were counted too and the check reported 17 vs 8 — a failure about
|
||||||
|
// two unrelated tables. Stop at the rule editor's script.
|
||||||
header := js[hStart:]
|
header := js[hStart:]
|
||||||
th := strings.Count(header, "<th>")
|
if cut := strings.Index(header, "/api/plugins/billing/rules"); cut > 0 {
|
||||||
|
header = header[:cut]
|
||||||
|
}
|
||||||
|
// Count <th> with OR without attributes: the name column now carries
|
||||||
|
// style='width:22%' so the header and its data cell can be given the same
|
||||||
|
// width, and a bare "<th>" count silently reported 7 vs 8 — the check
|
||||||
|
// failing on the very column it had just been taught to size.
|
||||||
|
th := strings.Count(header, "<th>") + strings.Count(header, "<th ")
|
||||||
|
|
||||||
rStart := strings.Index(js, "function row")
|
rStart := strings.Index(js, "function row")
|
||||||
rEnd := strings.Index(js, "function tableFor")
|
rEnd := strings.Index(js, "function tableFor")
|
||||||
|
|||||||
@ -869,18 +869,16 @@ func (ps *Plugins) List() []map[string]interface{} {
|
|||||||
}
|
}
|
||||||
if p.UI != nil {
|
if p.UI != nil {
|
||||||
ui := map[string]interface{}{}
|
ui := map[string]interface{}{}
|
||||||
if p.UI.Page != nil {
|
// Every contributed page, listed under `pages`, INCLUDING the single
|
||||||
ui["page"] = p.UI.Page.PageID
|
// `page` one. Two keys for the same thing looks redundant until you
|
||||||
|
// try to render the count: the management panel asked for `pages`,
|
||||||
|
// got an empty list for a plugin that plainly has a page, and showed
|
||||||
|
// "contributes 0 pages" next to a working screen. `page` stays as the
|
||||||
|
// backwards-compatible single-page accessor.
|
||||||
|
ids := make([]string, 0, len(p.UI.Pages)+1)
|
||||||
|
if p.UI.Page != nil && p.UI.Page.PageID != "" {
|
||||||
|
ids = append(ids, p.UI.Page.PageID)
|
||||||
}
|
}
|
||||||
if len(p.UI.Pages) > 0 {
|
|
||||||
// Every contributed page, not just the first. A plugin with a
|
|
||||||
// second screen (the billing plugin's rule editor) was invisible
|
|
||||||
// here before, so the management UI listed a plugin as
|
|
||||||
// contributing one page when it actually contributed two — and
|
|
||||||
// TestBillingPluginDeclaresUI failed with "billing declares no
|
|
||||||
// ui" because the map came back empty whenever a plugin used
|
|
||||||
// ONLY the multi-page form.
|
|
||||||
ids := make([]string, 0, len(p.UI.Pages))
|
|
||||||
for _, pg := range p.UI.Pages {
|
for _, pg := range p.UI.Pages {
|
||||||
if pg != nil && pg.PageID != "" {
|
if pg != nil && pg.PageID != "" {
|
||||||
ids = append(ids, pg.PageID)
|
ids = append(ids, pg.PageID)
|
||||||
@ -888,6 +886,8 @@ func (ps *Plugins) List() []map[string]interface{} {
|
|||||||
}
|
}
|
||||||
if len(ids) > 0 {
|
if len(ids) > 0 {
|
||||||
ui["pages"] = ids
|
ui["pages"] = ids
|
||||||
|
if p.UI.Page != nil {
|
||||||
|
ui["page"] = p.UI.Page.PageID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(p.UI.Elements) > 0 {
|
if len(p.UI.Elements) > 0 {
|
||||||
|
|||||||
@ -510,6 +510,22 @@ plugin.ui = {
|
|||||||
order = 40,
|
order = 40,
|
||||||
mount = [==[
|
mount = [==[
|
||||||
<div id="billing-root" style="padding:16px;min-width:0;max-width:100%;overflow-x:auto">
|
<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">
|
<div class="filter-line" style="margin:0 0 12px">
|
||||||
<span class="muted" id="billing-period-label"></span>
|
<span class="muted" id="billing-period-label"></span>
|
||||||
<select id="billing-period" onchange="window.__billing_setPeriod(this.value)">
|
<select id="billing-period" onchange="window.__billing_setPeriod(this.value)">
|
||||||
@ -518,7 +534,10 @@ plugin.ui = {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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 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">
|
<div class="card" style="padding:14px">
|
||||||
<h3 id="billing-h-src" style="margin:0 0 10px;font-size:14px"></h3>
|
<h3 id="billing-h-src" style="margin:0 0 10px;font-size:14px"></h3>
|
||||||
<div id="billing-by-source"></div>
|
<div id="billing-by-source"></div>
|
||||||
@ -532,11 +551,18 @@ plugin.ui = {
|
|||||||
<div id="billing-by-key"></div>
|
<div id="billing-by-key"></div>
|
||||||
</div>
|
</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>
|
<h3 id="billing-h-day" style="margin:0 0 10px;font-size:14px"></h3>
|
||||||
<div id="billing-by-day"></div>
|
<div id="billing-by-day"></div>
|
||||||
</div>
|
</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>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var ROOT = "billing";
|
var ROOT = "billing";
|
||||||
@ -556,6 +582,12 @@ plugin.ui = {
|
|||||||
thName: "name", thCost: "cost", thReqs: "reqs", thPrompt: "prompt",
|
thName: "name", thCost: "cost", thReqs: "reqs", thPrompt: "prompt",
|
||||||
thFresh: "fresh", thCache: "cache", thCachePct: "cache%",
|
thFresh: "fresh", thCache: "cache", thCachePct: "cache%",
|
||||||
thCompletion: "completion",
|
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",
|
noData: "no data yet",
|
||||||
periodLabel: "Period", periodDay: "Today", periodWeek: "This week",
|
periodLabel: "Period", periodDay: "Today", periodWeek: "This week",
|
||||||
periodMonth: "This month", periodAll: "All time",
|
periodMonth: "This month", periodAll: "All time",
|
||||||
@ -571,6 +603,7 @@ plugin.ui = {
|
|||||||
thName: "名称", thCost: "开销", thReqs: "请求", thPrompt: "输入",
|
thName: "名称", thCost: "开销", thReqs: "请求", thPrompt: "输入",
|
||||||
thFresh: "新鲜", thCache: "缓存", thCachePct: "缓存%",
|
thFresh: "新鲜", thCache: "缓存", thCachePct: "缓存%",
|
||||||
thCompletion: "输出",
|
thCompletion: "输出",
|
||||||
|
thInSplit: "输入(新鲜+缓存)",
|
||||||
noData: "暂无数据",
|
noData: "暂无数据",
|
||||||
periodLabel: "统计周期", periodDay: "今日", periodWeek: "本周",
|
periodLabel: "统计周期", periodDay: "今日", periodWeek: "本周",
|
||||||
periodMonth: "本月", periodAll: "全部",
|
periodMonth: "本月", periodAll: "全部",
|
||||||
@ -712,33 +745,44 @@ plugin.ui = {
|
|||||||
}
|
}
|
||||||
function row(name, b, cur) {
|
function row(name, b, cur) {
|
||||||
var fresh = (b.cache_fresh_tokens === undefined) ? (b.prompt_tokens || 0) : b.cache_fresh_tokens;
|
var fresh = (b.cache_fresh_tokens === undefined) ? (b.prompt_tokens || 0) : b.cache_fresh_tokens;
|
||||||
// 名称列 ellipsis(title 悬停看全名);数字列 break-all 在列宽内换行而不是
|
// 名称列 ellipsis(title 悬停看全名);数字列不折行。
|
||||||
// 把表格撑出卡片。单元格结构与列数不变,列数判据不受影响。
|
//
|
||||||
return "<tr><td style='overflow:hidden'><b style='display:block;white-space:nowrap;overflow:hidden;text-overflow: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, "'")) + "'>" + esc(name) + "</b></td>" +
|
esc(String(name).replace(/'/g, "'")) + "'>" + esc(name) + "</b></td>" +
|
||||||
"<td style='word-break:break-all'>" + money(b.cost, cur) + "</td>" +
|
"<td class='bnum'>" + money(b.cost, cur) + "</td>" +
|
||||||
"<td style='word-break:break-all'>" + fmtInt(b.requests || 0) + "</td>" +
|
"<td class='bnum'>" + fmtInt(b.requests || 0) + "</td>" +
|
||||||
"<td style='word-break:break-all'>" + fmtInt(b.prompt_tokens || 0) + "</td>" +
|
"<td class='bnum' title='" + esc(detail) + "'>" + fmtInt(b.prompt_tokens || 0) + "</td>" +
|
||||||
"<td style='word-break:break-all'>" + fmtInt(fresh) + "</td>" +
|
"<td class='bnum' title='" + esc(cacheRate(b)) + "'>" + esc(cacheRate(b)) + "</td>" +
|
||||||
"<td style='word-break:break-all'>" + fmtInt(b.cache_hit_tokens || 0) + "</td>" +
|
"<td class='bnum'>" + fmtInt(b.completion_tokens || 0) + "</td></tr>";
|
||||||
"<td style='word-break:break-all'>" + esc(cacheRate(b)) + "</td>" +
|
|
||||||
"<td style='word-break:break-all'>" + fmtInt(b.completion_tokens || 0) + "</td></tr>";
|
|
||||||
}
|
}
|
||||||
function tableFor(el, obj, cur, empty) {
|
function tableFor(el, obj, cur, empty) {
|
||||||
var keys = Object.keys(obj || {});
|
var keys = Object.keys(obj || {});
|
||||||
if (!keys.length) { el.innerHTML = '<div class="muted">' + empty + "</div>"; return; }
|
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); });
|
keys.sort(function (a, b) { return (obj[b].cost || 0) - (obj[a].cost || 0); });
|
||||||
var TH = L();
|
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 +
|
"<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.thReqs + "</th><th>" + TH.thInSplit +
|
||||||
"</th><th>" + TH.thFresh + "</th><th>" + TH.thCache + "</th><th>" + TH.thCachePct +
|
"</th><th>" + TH.thCachePct + "</th><th>" + TH.thCompletion + "</th></tr>";
|
||||||
"</th><th>" + TH.thCompletion + "</th></tr>";
|
|
||||||
for (var i = 0; i < keys.length; i++) {
|
for (var i = 0; i < keys.length; i++) {
|
||||||
var k = keys[i];
|
var k = keys[i];
|
||||||
h += "<tr style='border-top:1px solid rgba(120,90,150,.14)'>" + row(k, obj[k], cur) + "</tr>";
|
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() {
|
function renderTitles() {
|
||||||
var T = L();
|
var T = L();
|
||||||
@ -820,6 +864,39 @@ plugin.ui = {
|
|||||||
renderTitles();
|
renderTitles();
|
||||||
refresh();
|
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
|
// 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
|
// 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
|
// only option is to reimplement the window rules in the test, which would
|
||||||
@ -836,107 +913,13 @@ plugin.ui = {
|
|||||||
if (pluginAPI.onLangChange) {
|
if (pluginAPI.onLangChange) {
|
||||||
pluginAPI.onLangChange(function () {
|
pluginAPI.onLangChange(function () {
|
||||||
renderTitles();
|
renderTitles();
|
||||||
|
paintView();
|
||||||
refresh();
|
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 () {
|
(function () {
|
||||||
var API = "/api/plugins/billing/rules";
|
var API = "/api/plugins/billing/rules";
|
||||||
var STR = {
|
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>";
|
return "<option value='" + o[0] + "'" + (rule.mode === o[0] ? " selected" : "") + ">" + esc(o[1]) + "</option>";
|
||||||
}).join("");
|
}).join("");
|
||||||
var p = rule.peak;
|
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 || "") + "'>" +
|
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'>⚠ " + esc(T.warnNoSource) + "</div>" : "") +
|
(unknown ? "<div style='color:#e0a33e;font-size:11px'>⚠ " + esc(T.warnNoSource) + "</div>" : "") +
|
||||||
"</td>" +
|
"</td>" +
|
||||||
"<td><select class='r-mode'>" + opts + "</select></td>" +
|
"<td style='width:78px'><select class='r-mode' style='width:100%;box-sizing:border-box'>" + opts + "</select></td>" +
|
||||||
"<td style='width:74px'><input class='r-cur' value='" + esc(rule.currency || "") + "' placeholder='USD'></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:92px'><input class='r-sub' type='number' step='0.01' value='" +
|
"<td style='width:76px'><input class='r-sub' type='number' step='0.01' value='" +
|
||||||
(rule.subscription ? esc(rule.subscription) : "") + "' placeholder='" + esc(T.monthly) + "'></td>" +
|
(rule.subscription ? esc(rule.subscription) : "") + "' placeholder='" + esc(T.monthly) + "' style='width:100%;box-sizing:border-box'></td>" +
|
||||||
"<td style='width:64px'><input class='r-mult' type='number' step='0.1' value='" +
|
"<td style='width:58px'><input class='r-mult' type='number' step='0.1' value='" +
|
||||||
(p && p.multiplier ? esc(p.multiplier) : "") + "' placeholder='" + esc(T.peakMult) + "'></td>" +
|
(p && p.multiplier ? esc(p.multiplier) : "") + "' placeholder='" + esc(T.peakMult) + "' style='width:100%;box-sizing:border-box'></td>" +
|
||||||
"<td style='width:104px'><input class='r-hours' value='" + esc(hoursText(p)) + "' placeholder='1-4,6-10'></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:74px'><input class='r-days' value='" + esc(daysText(p)) + "' placeholder='1,2,3'></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:210px'>" + modelBlock(rule.models, T) + "</td>" +
|
"<td style='min-width:180px'>" + modelBlock(rule.models, T) + "</td>" +
|
||||||
"<td><button class='ghost small r-del'>" + esc(T.delRule) + "</button></td></tr>";
|
"<td style='width:56px'><button class='ghost small r-del'>" + esc(T.delRule) + "</button></td></tr>";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- collecting one rule back out of the DOM ----------------------------
|
// ---- collecting one rule back out of the DOM ----------------------------
|
||||||
@ -1260,7 +1247,7 @@ table.insert(plugin.ui.pages, {
|
|||||||
"<span class='grow'></span>" +
|
"<span class='grow'></span>" +
|
||||||
"<button class='ghost small p-add' data-pid='" + esc(p.id) + "'>" + esc(T.addRule) + "</button>" +
|
"<button class='ghost small p-add' data-pid='" + esc(p.id) + "'>" + esc(T.addRule) + "</button>" +
|
||||||
"</div>" +
|
"</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'>" +
|
"<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.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>" +
|
"<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();
|
render();
|
||||||
load();
|
load();
|
||||||
if (window.pluginAPI && pluginAPI.onTabShown) pluginAPI.onTabShown(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>
|
</script>
|
||||||
]==],
|
]==],
|
||||||
})
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return plugin
|
return plugin
|
||||||
127
internal/scheduler/empty_response_test.go
Normal file
127
internal/scheduler/empty_response_test.go
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
package scheduler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"llmsproxy/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestResultIsEmptyMatchesTheReasoningOnlyResponse guards the production bug
|
||||||
|
// behind "model AUTO returned a completed response with no content".
|
||||||
|
//
|
||||||
|
// Production measurement: 2 of 20 AUTO requests returned zero content, both
|
||||||
|
// served by claude-opus-4-8. Direct upstream capture showed 28 chunks of
|
||||||
|
// `reasoning_content` and NO `content`, finish_reason=length — the token budget
|
||||||
|
// was consumed by thinking before any text was emitted. runTier accepted it
|
||||||
|
// because err == nil.
|
||||||
|
func TestResultIsEmptyMatchesTheReasoningOnlyResponse(t *testing.T) {
|
||||||
|
// Exactly what the upstream returned, as a decoded response.
|
||||||
|
onlyReasoning := &types.UnifiedResponse{
|
||||||
|
Model: "claude-opus-4-8",
|
||||||
|
ReasoningContent: "Alright, the user just said \"hi\". Simple greeting…",
|
||||||
|
FinishReason: "length",
|
||||||
|
TokenUsage: types.TokenUsage{Prompt: 14, Completion: 30, Total: 44},
|
||||||
|
}
|
||||||
|
if !resultIsEmpty(onlyReasoning) {
|
||||||
|
t.Error("a reasoning-only response with usage must count as empty; " +
|
||||||
|
"it is what the client sees as \"completed with no content\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same response WITH text is a normal answer.
|
||||||
|
if resultIsEmpty(&types.UnifiedResponse{Content: "Hi! 👋", ReasoningContent: "hmm"}) {
|
||||||
|
t.Error("a response with content is not empty")
|
||||||
|
}
|
||||||
|
// A tool-calling agent turn has no text and is perfectly valid.
|
||||||
|
if resultIsEmpty(&types.UnifiedResponse{
|
||||||
|
ToolCalls: []types.ToolCall{{Name: "read_file", Arguments: map[string]interface{}{"path": "x"}}},
|
||||||
|
}) {
|
||||||
|
t.Error("a tool-call-only response must NOT count as empty — agents " +
|
||||||
|
"legitimately produce tool calls with no text")
|
||||||
|
}
|
||||||
|
// An image slot returns no text by design.
|
||||||
|
if resultIsEmpty(&types.UnifiedResponse{
|
||||||
|
ImageData: []types.ImageData{{URL: "http://x/y.png"}},
|
||||||
|
}) {
|
||||||
|
t.Error("an image response must NOT count as empty")
|
||||||
|
}
|
||||||
|
// Whitespace-only content is as useless to a client as none at all.
|
||||||
|
if !resultIsEmpty(&types.UnifiedResponse{Content: " \n\t"}) {
|
||||||
|
t.Error("whitespace-only content must count as empty")
|
||||||
|
}
|
||||||
|
if resultIsEmpty(nil) {
|
||||||
|
t.Error("nil is not an empty response; it is an absent one")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPeekStreamHoldsReasoningAndReportsEmpty is the streaming half: a channel
|
||||||
|
// of reasoning-only chunks must be reported as empty so chainDrive degrades.
|
||||||
|
func TestPeekStreamHoldsReasoningAndReportsEmpty(t *testing.T) {
|
||||||
|
in := make(chan types.UnifiedChunk, 8)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
in <- types.UnifiedChunk{ReasoningContent: "thinking…"}
|
||||||
|
}
|
||||||
|
in <- types.UnifiedChunk{Done: true, FinishReason: "length"}
|
||||||
|
close(in)
|
||||||
|
|
||||||
|
_, peek := peekStream(in)
|
||||||
|
if peek() {
|
||||||
|
t.Error("a reasoning-only stream must be reported empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPeekStreamForwardsContentAfterReasoning: the common case still works,
|
||||||
|
// and the reasoning preamble is not forwarded ahead of the content (holding it
|
||||||
|
// back is what keeps the degrade path available).
|
||||||
|
func TestPeekStreamForwardsContentAfterReasoning(t *testing.T) {
|
||||||
|
in := make(chan types.UnifiedChunk, 8)
|
||||||
|
in <- types.UnifiedChunk{ReasoningContent: "let me think"}
|
||||||
|
in <- types.UnifiedChunk{Content: "Hi"}
|
||||||
|
in <- types.UnifiedChunk{Content: "!"}
|
||||||
|
in <- types.UnifiedChunk{Done: true, FinishReason: "stop"}
|
||||||
|
close(in)
|
||||||
|
|
||||||
|
out, peek := peekStream(in)
|
||||||
|
if !peek() {
|
||||||
|
t.Fatal("a stream with content must be reported as having content")
|
||||||
|
}
|
||||||
|
var got []string
|
||||||
|
for ck := range out {
|
||||||
|
if ck.Content != "" {
|
||||||
|
got = append(got, ck.Content)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(ck.ReasoningContent) != "" {
|
||||||
|
t.Error("reasoning preamble must not be forwarded before content; " +
|
||||||
|
"that is what pins the client to a stream it cannot escape")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Join(got, "") != "Hi!" {
|
||||||
|
t.Errorf("forwarded content = %q, want %q", got, "Hi!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPeekStreamDoesNotDeadlockOnToolCalls: a tool-call delta is content for
|
||||||
|
// this purpose and must unblock peek immediately.
|
||||||
|
func TestPeekStreamDoesNotDeadlockOnToolCalls(t *testing.T) {
|
||||||
|
raw, _ := json.Marshal([]types.ToolCall{{Name: "ls"}})
|
||||||
|
in := make(chan types.UnifiedChunk, 4)
|
||||||
|
in <- types.UnifiedChunk{ToolCalls: raw}
|
||||||
|
in <- types.UnifiedChunk{Done: true, FinishReason: "tool_calls"}
|
||||||
|
close(in)
|
||||||
|
|
||||||
|
_, peek := peekStream(in)
|
||||||
|
done := make(chan bool, 1)
|
||||||
|
go func() { done <- peek() }()
|
||||||
|
select {
|
||||||
|
case ok := <-done:
|
||||||
|
if !ok {
|
||||||
|
t.Error("a tool-call delta must count as content")
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("peek blocked on a tool-call-only stream")
|
||||||
|
}
|
||||||
|
_ = context.Background()
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -245,6 +246,115 @@ func normalCount(cands []candidate) int {
|
|||||||
//
|
//
|
||||||
// Normal candidates rotate by base; probe candidates form a fixed tail tried
|
// Normal candidates rotate by base; probe candidates form a fixed tail tried
|
||||||
// only after every normal slot failed or was busy.
|
// only after every normal slot failed or was busy.
|
||||||
|
// emptyResultReason describes why a 200-with-no-content response counts as a
|
||||||
|
// slot failure for AUTO.
|
||||||
|
//
|
||||||
|
// WHY: reasoning models (claude-opus-*, codebuddy_glm-*, …) emit
|
||||||
|
// `reasoning_content` first and only then `content`. When the caller's
|
||||||
|
// max_tokens is small enough that the thinking phase consumes the whole budget,
|
||||||
|
// upstream returns 200 / finish_reason=length with 28 chunks of reasoning and
|
||||||
|
// ZERO content. runTier treated "err == nil" as success and handed that to the
|
||||||
|
// client, which then failed with "returned a completed response with no
|
||||||
|
// content" — a client-side error message for what is really a bad slot choice.
|
||||||
|
//
|
||||||
|
// So an empty result is a SLOT failure, not a request failure: the gateway
|
||||||
|
// degrades to the next slot and the user still gets an answer. Measured on
|
||||||
|
// production AUTO: 2 of 20 requests returned empty content, all of them
|
||||||
|
// claude-opus-4-8.
|
||||||
|
//
|
||||||
|
// A response carrying tool_calls or image data is NOT empty: an agent turn
|
||||||
|
// legitimately produces tool calls with no text. ReasoningContent does NOT
|
||||||
|
// rescue it either — see resultIsEmpty.
|
||||||
|
const emptyResultReason = "upstream returned no content (reasoning-only response, or the token budget was consumed before any text)"
|
||||||
|
|
||||||
|
// resultIsEmpty reports whether a successful-but-useless response should be
|
||||||
|
// treated as a slot failure.
|
||||||
|
//
|
||||||
|
// Image data counts: an image-generation slot legitimately returns no text.
|
||||||
|
// A usage-only response is NOT empty either — the upstream answered, it just
|
||||||
|
// said nothing, and that is exactly the case worth degrading away from.
|
||||||
|
func resultIsEmpty(resp *types.UnifiedResponse) bool {
|
||||||
|
if resp == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// NOTE: ReasoningContent is deliberately NOT consulted. My first version
|
||||||
|
// excluded it ("the model was thinking, that is an answer"), and the test
|
||||||
|
// built from the real production capture failed immediately: the captured
|
||||||
|
// response is exactly reasoning_content-with-usage and zero text. The
|
||||||
|
// client asked for text and there is none; holding a request hostage to
|
||||||
|
// another model's thinking phase is strictly worse than degrading.
|
||||||
|
return strings.TrimSpace(resp.Content) == "" &&
|
||||||
|
len(resp.ToolCalls) == 0 &&
|
||||||
|
len(resp.ImageData) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// emptyStreamReason is resultIsEmpty's streaming twin; see emptyResultReason
|
||||||
|
// for why an empty response is a slot failure rather than a request failure.
|
||||||
|
const emptyStreamReason = emptyResultReason
|
||||||
|
|
||||||
|
// peekStream wraps a chunk channel so the caller learns whether the stream
|
||||||
|
// produced real content BEFORE the chunks are forwarded.
|
||||||
|
//
|
||||||
|
// Why this is necessary: reasoning models emit reasoning_content first. With a
|
||||||
|
// small max_tokens the whole budget is spent thinking, the stream ends with
|
||||||
|
// finish_reason=length and zero content. If the gateway forwarded those chunks
|
||||||
|
// as they arrived, the client would already have seen a 200 SSE stream and
|
||||||
|
// could not be given a different slot — its only recourse is the useless
|
||||||
|
// "returned a completed response with no content" error. Buffering until the
|
||||||
|
// first real content (or the end of the stream) keeps the degrade path
|
||||||
|
// available at the cost of holding back the first few chunks.
|
||||||
|
//
|
||||||
|
// What is NOT buffered: the wrapper starts forwarding as soon as a chunk with
|
||||||
|
// non-empty Content or ToolCalls arrives, and keeps forwarding everything from
|
||||||
|
// then on, so only the reasoning preamble is held. Reasoning-only responses
|
||||||
|
// are dropped in full and reported as empty, which lets chainDrive try the
|
||||||
|
// next slot.
|
||||||
|
func peekStream(in <-chan types.UnifiedChunk) (<-chan types.UnifiedChunk, func() bool) {
|
||||||
|
out := make(chan types.UnifiedChunk, 16)
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
sawText bool
|
||||||
|
done bool
|
||||||
|
)
|
||||||
|
go func() {
|
||||||
|
defer close(out)
|
||||||
|
started := false
|
||||||
|
for ck := range in {
|
||||||
|
if !started {
|
||||||
|
// Hold back the reasoning / usage-only preamble. A tool-call
|
||||||
|
// delta counts as content: an agent turn legitimately emits
|
||||||
|
// tool_calls with no text.
|
||||||
|
if strings.TrimSpace(ck.Content) == "" && len(ck.ToolCalls) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
started = true
|
||||||
|
mu.Lock()
|
||||||
|
sawText = true
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
out <- ck
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
done = true
|
||||||
|
mu.Unlock()
|
||||||
|
}()
|
||||||
|
// peek blocks until the stream either produces content or ends, then
|
||||||
|
// reports whether any content was seen. Polling a 2ms tick rather than
|
||||||
|
// using a second channel keeps peekStream single-goroutine and leak-free.
|
||||||
|
peek := func() bool {
|
||||||
|
for {
|
||||||
|
mu.Lock()
|
||||||
|
seen, finished := sawText, done
|
||||||
|
mu.Unlock()
|
||||||
|
if seen || finished {
|
||||||
|
return seen
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, peek
|
||||||
|
}
|
||||||
|
|
||||||
func runTier(ctx context.Context, tn *TierNode, cands []candidate, base int64, req *types.ChatRequest, stream bool) tierResult {
|
func runTier(ctx context.Context, tn *TierNode, cands []candidate, base int64, req *types.ChatRequest, stream bool) tierResult {
|
||||||
n := len(cands)
|
n := len(cands)
|
||||||
norm := normalCount(cands)
|
norm := normalCount(cands)
|
||||||
@ -267,7 +377,18 @@ func runTier(ctx context.Context, tn *TierNode, cands []candidate, base int64, r
|
|||||||
if stream {
|
if stream {
|
||||||
chunks, err := sl.Prov.ChatStream(ctx, &r)
|
chunks, err := sl.Prov.ChatStream(ctx, &r)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return tierResult{chunks: chunks, src: sl.Source, model: sl.Model}
|
guarded, peek := peekStream(chunks)
|
||||||
|
if peek() {
|
||||||
|
return tierResult{chunks: guarded, src: sl.Source, model: sl.Model}
|
||||||
|
}
|
||||||
|
// The stream finished with no content at all: a
|
||||||
|
// reasoning-only response. Drain and move on to the next
|
||||||
|
// slot instead of pinning the client to a useless stream.
|
||||||
|
hard = append(hard, TierError{
|
||||||
|
Tier: tn.Tier, Source: sl.Source, Model: sl.Model,
|
||||||
|
Err: errors.New(emptyStreamReason),
|
||||||
|
})
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return tierResult{}
|
return tierResult{}
|
||||||
@ -280,6 +401,17 @@ func runTier(ctx context.Context, tn *TierNode, cands []candidate, base int64, r
|
|||||||
}
|
}
|
||||||
resp, err := sl.Prov.Chat(ctx, &r)
|
resp, err := sl.Prov.Chat(ctx, &r)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
if resultIsEmpty(resp) {
|
||||||
|
// Soft failure: record it and try the next slot. Deliberately
|
||||||
|
// NOT a hard TierError — a hard error is reported to the client
|
||||||
|
// verbatim when the whole chain fails, and "this one model was
|
||||||
|
// unhelpful" is not the client's problem to debug.
|
||||||
|
hard = append(hard, TierError{
|
||||||
|
Tier: tn.Tier, Source: sl.Source, Model: sl.Model,
|
||||||
|
Err: errors.New(emptyResultReason),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
return tierResult{resp: resp, src: sl.Source, model: sl.Model}
|
return tierResult{resp: resp, src: sl.Source, model: sl.Model}
|
||||||
}
|
}
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user