diff --git a/changelog.d/fixes/ollama-qwen3-length-stop-reason.md b/changelog.d/fixes/ollama-qwen3-length-stop-reason.md new file mode 100644 index 0000000000..c28cf95cbf --- /dev/null +++ b/changelog.d/fixes/ollama-qwen3-length-stop-reason.md @@ -0,0 +1 @@ +- **fix(sse):** `detectMalformedNonStream` no longer flags a Claude-format message as malformed when the content array only contains empty text blocks or the `(empty response)` sentinel and `stop_reason` is `"length"` — a legitimate truncation (e.g. `ollama/qwen3:1.7b` exhausting its reasoning budget before producing visible text), not a real 502-worthy empty response. diff --git a/open-sse/utils/diagnostics.ts b/open-sse/utils/diagnostics.ts index bcfccee42b..0ae8bf08e8 100644 --- a/open-sse/utils/diagnostics.ts +++ b/open-sse/utils/diagnostics.ts @@ -291,7 +291,11 @@ export function detectMalformedNonStream( // text:""}] — one block, just with no visible text — which is the exact // same legitimate truncated-completion shape, so the exemption must apply // whenever there is no visible output, not only when content is []. - if (stopReason === "max_tokens" || stopReason === "tool_use") return null; + // "length" is the OpenAI-style spelling some Claude-compatible shims + // (ollama qwen3 with the reasoning budget exhausted) emit for the same + // truncated-completion case — sentinel content + stop_reason "length". + if (stopReason === "max_tokens" || stopReason === "tool_use" || stopReason === "length") + return null; // content:[] with no stop_reason at all is non-terminal, not empty (#9971). if (content.length === 0 && stopReason.length === 0) return null; return "empty_choices"; diff --git a/tests/unit/diagnostics.test.ts b/tests/unit/diagnostics.test.ts index 62da56fc0c..d5b5d1dcff 100644 --- a/tests/unit/diagnostics.test.ts +++ b/tests/unit/diagnostics.test.ts @@ -56,6 +56,19 @@ test("synthOpenAIErrorChunk references provider in message", () => { ); }); +test("detectMalformedNonStream allows Claude message with (empty response) + stop_reason=length (ollama qwen3)", () => { + const resp = { + type: "message", + content: [{ type: "text", text: "(empty response)" }], + stop_reason: "length", + }; + assert.strictEqual( + detectMalformedNonStream(resp), + null, + "ollama reasoning truncation should not be empty_choices" + ); +}); + // ── (b) synthResponsesFailure matches a response.failed event ──────────────── test("synthResponsesFailure produces a response.failed SSE event", () => {