Repository navigation
fix(sse): drop empty-choices chunks without usage instead of injecting retry text (#3502) #3513
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
diegosouzapw
merged 1 commit into
release/v3.8.18
from
fix/empty-response-retry-loop-3502
Jun 9, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| import test from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import fs from "node:fs"; | ||
| import os from "node:os"; | ||
| import path from "node:path"; | ||
|
|
||
| const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-empty-inject-")); | ||
| process.env.DATA_DIR = TEST_DATA_DIR; | ||
| const core = await import("../../src/lib/db/core.ts"); | ||
|
|
||
| const { createSSEStream } = await import("../../open-sse/utils/stream.ts"); | ||
| const { FORMATS } = await import("../../open-sse/translator/formats.ts"); | ||
|
|
||
| const textEncoder = new TextEncoder(); | ||
|
|
||
| async function readTransformed(chunks: string[], options: Record<string, unknown>) { | ||
| const source = new ReadableStream({ | ||
| start(controller) { | ||
| for (const chunk of chunks) { | ||
| controller.enqueue(textEncoder.encode(chunk)); | ||
| } | ||
| controller.close(); | ||
| }, | ||
| }); | ||
| return new Response(source.pipeThrough(createSSEStream(options as never))).text(); | ||
| } | ||
|
|
||
| test.after(() => { | ||
| core.resetDbInstance(); | ||
| if (fs.existsSync(TEST_DATA_DIR)) { | ||
| fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
|
|
||
| // Regression guard for #3502 / #3388: PR #3422 ("allow OpenAI usage-only empty | ||
| // choices chunks") re-introduced the assistant-content injection | ||
| // "[OmniRoute] Upstream returned an empty response. Please retry." for empty | ||
| // `choices: []` chunks that carry NO valid usage. That injected text is fed back | ||
| // by clients (Goose/opencode) as a turn, producing the retry loop #3400 fixed. | ||
| // An empty-no-usage chunk must be DROPPED, never injected as content. | ||
| test("empty choices WITHOUT usage are dropped, not injected as retry text (#3502)", async () => { | ||
| const text = await readTransformed( | ||
| [ | ||
| `data: ${JSON.stringify({ | ||
| id: "chatcmpl_x", | ||
| object: "chat.completion.chunk", | ||
| created: 1, | ||
| model: "mimo-v2.5-free", | ||
| choices: [], | ||
| })}\n\n`, | ||
| `data: [DONE]\n\n`, | ||
| ], | ||
| { | ||
| mode: "passthrough", | ||
| sourceFormat: FORMATS.OPENAI, | ||
| provider: "opencode-zen", | ||
| model: "mimo-v2.5-free", | ||
| body: { messages: [{ role: "user", content: "hi" }] }, | ||
| } | ||
| ); | ||
|
|
||
| assert.doesNotMatch(text, /Upstream returned an empty response/); | ||
| assert.doesNotMatch(text, /Please retry/); | ||
| }); | ||
|
|
||
| // #3422 behavior that MUST be preserved: a standards-compliant | ||
| // `stream_options.include_usage` final chunk has `choices: []` WITH usage and | ||
| // must be forwarded (not dropped, not turned into an error). | ||
| test("empty choices WITH valid usage are forwarded (preserve #3422)", async () => { | ||
| const text = await readTransformed( | ||
| [ | ||
| `data: ${JSON.stringify({ | ||
| id: "chatcmpl_y", | ||
| object: "chat.completion.chunk", | ||
| created: 1, | ||
| model: "gpt-4.1-mini", | ||
| choices: [{ index: 0, delta: { role: "assistant", content: "hello" } }], | ||
| })}\n\n`, | ||
| `data: ${JSON.stringify({ | ||
| id: "chatcmpl_y", | ||
| object: "chat.completion.chunk", | ||
| created: 1, | ||
| model: "gpt-4.1-mini", | ||
| choices: [], | ||
| usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 }, | ||
| })}\n\n`, | ||
| `data: [DONE]\n\n`, | ||
| ], | ||
| { | ||
| mode: "passthrough", | ||
| sourceFormat: FORMATS.OPENAI, | ||
| provider: "openai", | ||
| model: "gpt-4.1-mini", | ||
| body: { messages: [{ role: "user", content: "hi" }] }, | ||
| } | ||
| ); | ||
|
|
||
| assert.doesNotMatch(text, /Upstream returned an empty response/); | ||
| assert.match(text, /"total_tokens":8/); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When dropping an empty
choices: []chunk, any pending SSE event header (e.g.,event: message) that was parsed right before it remains in the buffer and is not cleared. This can cause the orphaned event header to be leaked or duplicated on subsequent chunks or empty lines, leading to malformed SSE streams. CallingclearPendingPassthroughEvent()before continuing ensures that the pending event header is properly discarded along with the dropped chunk.