diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html
index a0ef555..ed2c656 100644
--- a/internal/gateway/ui/index.html
+++ b/internal/gateway/ui/index.html
@@ -5403,7 +5403,18 @@
// 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(pg.icon);
+ //
+ // The label is NOT optional. Every native tab is
+ // `标题`; 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) +
+ '' +
+ esc(pg.title || id) +
+ "";
btn.onclick = () => goTab(id);
nav.appendChild(btn);
PLUGIN_PAGES.add(id);
diff --git a/internal/gateway/ui_plugin_test.go b/internal/gateway/ui_plugin_test.go
index 38ee56d..0a5e2a2 100644
--- a/internal/gateway/ui_plugin_test.go
+++ b/internal/gateway/ui_plugin_test.go
@@ -366,3 +366,25 @@ func TestPluginPagesAreReachableByGoTab(t *testing.T) {
"declaration must come first", decl, goTab)
}
}
+
+// TestPluginSidebarEntryCarriesATitle: a plugin tab must render icon AND label.
+//
+// Native tabs are `状态`; 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)
+}
diff --git a/internal/lua/billing_test.go b/internal/lua/billing_test.go
index 01610d4..6cd362a 100644
--- a/internal/lua/billing_test.go
+++ b/internal/lua/billing_test.go
@@ -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- 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 {
diff --git a/internal/lua/billing_ui_test.go b/internal/lua/billing_ui_test.go
index 877cb42..4f73af1 100644
--- a/internal/lua/billing_ui_test.go
+++ b/internal/lua/billing_ui_test.go
@@ -309,8 +309,20 @@ func TestBillingTableHeaderMatchesRowColumns(t *testing.T) {
if hStart < 0 {
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:]
- th := strings.Count(header, "
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 "
" count silently reported 7 vs 8 — the check
+ // failing on the very column it had just been taught to size.
+ th := strings.Count(header, "
") + strings.Count(header, "
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 {
- if pg != nil && pg.PageID != "" {
- ids = append(ids, pg.PageID)
- }
+ for _, pg := range p.UI.Pages {
+ if pg != nil && pg.PageID != "" {
+ ids = append(ids, pg.PageID)
}
- if len(ids) > 0 {
- ui["pages"] = ids
+ }
+ if len(ids) > 0 {
+ ui["pages"] = ids
+ if p.UI.Page != nil {
+ ui["page"] = p.UI.Page.PageID
}
}
if len(p.UI.Elements) > 0 {
diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua
index 9284975..0c70ae7 100644
--- a/internal/lua/plugins/billing.lua
+++ b/internal/lua/plugins/billing.lua
@@ -510,6 +510,22 @@ plugin.ui = {
order = 40,
mount = [==[
+
+
+
+
+
+
+
+
+
+
-
+
+
@@ -532,10 +551,17 @@ plugin.ui = {
-
+
+
+
+
+
+
…
+
-]==],
- },
- -- 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 = [==[
-
-
-
—
-
-
-
-]==],
- },
- },
-}
--- 第二页:计费规则编辑。
---
--- 与统计页分开,而不是塞进同一页的标签里:规则是可编辑的配置,统计是只读的
--- 结果,混在一个页面里会让"改数字"和"看数字"的边界变模糊。
--- 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 = [==[]==],
- order = 41,
- mount = [==[
-
-
…
-
-
+]==],
+ },
+ -- 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 = [==[
+
+
+
—
+
+
+
]==],
-})
+ },
+ },
+}
+
return plugin
\ No newline at end of file
diff --git a/internal/scheduler/empty_response_test.go b/internal/scheduler/empty_response_test.go
new file mode 100644
index 0000000..b86369d
--- /dev/null
+++ b/internal/scheduler/empty_response_test.go
@@ -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()
+}
diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go
index 9fa0f4f..3f467ce 100644
--- a/internal/scheduler/scheduler.go
+++ b/internal/scheduler/scheduler.go
@@ -11,6 +11,7 @@ import (
"fmt"
"sort"
"strings"
+ "sync"
"sync/atomic"
"time"
@@ -245,6 +246,115 @@ func normalCount(cands []candidate) int {
//
// Normal candidates rotate by base; probe candidates form a fixed tail tried
// 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 {
n := len(cands)
norm := normalCount(cands)
@@ -267,7 +377,18 @@ func runTier(ctx context.Context, tn *TierNode, cands []candidate, base int64, r
if stream {
chunks, err := sl.Prov.ChatStream(ctx, &r)
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 {
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)
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}
}
if ctx.Err() != nil {