mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 12:22:14 +03:00
An `auto/*` combo whose first step lands on an uncredentialed backend returns HTTP 200 with `finish_reason: "stop"`, `content: null` and `error: null`. The agent sees a clean empty assistant turn, has no error to stop on, and retries to its cap. The non-streaming path already refuses this: `isEmptyContentResponse` rewrites a 200-with-no-content into a 502 "Provider returned empty content", which the combo layer classifies as a model-level transient and fails over on (#5085). The streaming path had no equivalent, and neither existing guard covers it: - `ensureStreamReadiness` is a LIVENESS probe, not a content one. Its failure message says so — "Stream ended before producing a non-ping SSE event" — and `hasStreamReadinessSignal` returns true for a bare `delta:{"role":"assistant"}`. - `createDisconnectAwareStream`'s #7699 branch fires on a MISSING terminal marker and is scoped to the Claude client format, because for other formats a marker-less close is genuinely ambiguous. The reported stream trips neither: OpenAI format, terminates with `finish_reason: "stop"` and `[DONE]`, contains nothing. "Completed normally but emitted zero content" is not ambiguous the way a missing marker is, so this guard is format-agnostic. It reuses `hasUsefulStreamContent`, which already existed in streamReadiness.ts — exported, correct, and wired to nothing — and which already counts tool-call-only and reasoning-only output as real (#2520). A watcher wraps it to handle frames split across network chunks and to spot the terminal states where emptiness is legitimate, kept in step with errorClassifier.ts's `LEGIT_EMPTY_OPENAI_FINISH` / `LEGIT_EMPTY_CLAUDE_STOP`: length, tool_calls, content_filter, max_tokens, tool_use. Two guards keep it from over-firing, one of which caught a real regression while building this: the check applies only when bytes were forwarded, and only when the body actually looked like SSE. A plain JSON completion travels through the same wrapper and has no `data:` frames, so "no content seen" says nothing about it — without the SSE gate, four existing stream tests failed. The `if (done)` branch's reasoning moved into `resolveSilentCloseReason()`, which also drops `pull` back under the function-length ceiling; cyclomatic lands at 2187 against a baseline of 2188. This surfaces the error rather than failing over. Failing over would mean holding every stream until its first content token, since the combo has already returned leg 1's response by then — a much larger change. Surfacing the error satisfies the issue's stated expectation ("surface the upstream error OR fail over") and stops the retry loop, which is the reported harm. Closes #8649