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:
@ -275,21 +275,14 @@ func TestBillingPluginDeclaresUI(t *testing.T) {
|
||||
if page, _ := ui["page"].(string); page != "billing" {
|
||||
t.Errorf("ui.page = %v, want \"billing\"", ui["page"])
|
||||
}
|
||||
// The management listing must name EVERY page, not just the first.
|
||||
// Without `pages`, a plugin contributing the totals page and the rule
|
||||
// editor is shown as contributing one, and the operator has no way to
|
||||
// tell from the plugin list that a second screen exists.
|
||||
// List() builds this map in Go, so the value is []string here; only
|
||||
// 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.
|
||||
// ONE page, not two. The rule editor and the totals share a sidebar
|
||||
// entry because billing is one thing: prices decide the numbers and the
|
||||
// numbers are the result of the prices. Split across two pages, saving a
|
||||
// price left no on-screen way to see its effect — the loop the operator
|
||||
// actually works in was cut in half.
|
||||
pages, _ := ui["pages"].([]string)
|
||||
found := map[string]bool{}
|
||||
for _, id := range pages {
|
||||
found[id] = true
|
||||
}
|
||||
if !found["billing-rules"] {
|
||||
t.Errorf("ui.pages = %v, want it to include billing-rules", found)
|
||||
if len(pages) != 1 || pages[0] != "billing" {
|
||||
t.Errorf("ui.pages = %v, want exactly one page [billing]", pages)
|
||||
}
|
||||
if n, _ := ui["elements"].(int); n < 1 {
|
||||
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")
|
||||
}
|
||||
|
||||
// TestBillingDeclaresRulesEditorPage: the rule editor is a SECOND screen, not a
|
||||
// tab inside the totals page. Two reasons it has to be its own page: mixing
|
||||
// editable configuration with read-only results blurs the line between "looking
|
||||
// at numbers" and "changing prices", and the plugin UI contract only ever had
|
||||
// room for one page — a second plugin contributing a page silently overwrote
|
||||
// the first, so multi-page had to become a first-class shape before this could
|
||||
// exist.
|
||||
func TestBillingDeclaresRulesEditorPage(t *testing.T) {
|
||||
// TestBillingRuleEditorLivesInsideTheBillingPage: the rule editor is a VIEW
|
||||
// inside the billing page, not a second sidebar entry.
|
||||
//
|
||||
// This started as a separate page and was wrong: saving a price sent the
|
||||
// operator to a different screen to find out whether it worked. A single page
|
||||
// with a view switch keeps the loop closed, and the rule editor already refreshes
|
||||
// the usage numbers on save, so the effect is visible immediately.
|
||||
//
|
||||
// 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)
|
||||
var ui *UIExtension = nil
|
||||
var ui *UIExtension
|
||||
for _, p := range ps.plugins {
|
||||
if p.Info.Name == "billing" {
|
||||
ui = p.UI
|
||||
}
|
||||
}
|
||||
if ui == nil {
|
||||
t.Fatal("billing plugin loaded with no UI extension")
|
||||
if ui == nil || ui.Page == nil {
|
||||
t.Fatal("billing plugin loaded with no page")
|
||||
}
|
||||
// Read the plugin's OWN extension, which is where the single `page` field
|
||||
// still lives — the fold into one list happens in the merged view, not here.
|
||||
ids := map[string]bool{}
|
||||
if ui.Page != nil {
|
||||
ids[ui.Page.PageID] = true
|
||||
if ui.Page.PageID != "billing" {
|
||||
t.Errorf("page id = %q, want billing", ui.Page.PageID)
|
||||
}
|
||||
for _, pg := range ui.Pages {
|
||||
if pg != nil {
|
||||
ids[pg.PageID] = true
|
||||
}
|
||||
}
|
||||
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"} {
|
||||
mount := ui.Page.Mount
|
||||
|
||||
// Both halves on one page.
|
||||
for _, needle := range []string{`id="billing-kpis"`, `id="billing-by-source"`} {
|
||||
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 {
|
||||
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] {
|
||||
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.
|
||||
if len(ui.Pages) != 5 {
|
||||
t.Fatalf("expected all 5 pages to merge, got %d: %v", len(ui.Pages), got)
|
||||
if len(ui.Pages) != 4 {
|
||||
t.Fatalf("expected all 4 pages to merge, got %d: %v", len(ui.Pages), got)
|
||||
}
|
||||
for i := 1; i < len(ui.Pages); i++ {
|
||||
if ui.Pages[i].Order < ui.Pages[i-1].Order {
|
||||
|
||||
Reference in New Issue
Block a user