From 3ce157d22e4b50e5ac8bbef8f3b8fdbbc01a1b7b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:53:07 -0300 Subject: [PATCH] fix(responses): detect stream readiness for tool-call-only and object-less chunks (#3612) (#3661) Closes #3612 --- CHANGELOG.md | 1 + open-sse/utils/streamReadiness.ts | 22 ++++++++++++++++------ tests/unit/stream-readiness.test.ts | 25 ++++++++++++++++++++++++- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 180105420f..5890bc4d29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ - **fix(reasoning): replay `reasoning_content` on plain DeepSeek turns** ([#3632] — thanks @adivekar-utexas): the reasoning-replay gate previously only fired when an assistant message already carried `reasoning_content`. Plain (non-tool-call) turns whose `reasoning_content` was stripped by the client (e.g. Cursor) were forwarded without it, so DeepSeek V4+ rejected the request with 400 "the reasoning_content in the thinking mode must be passed back". The gate now also covers missing/empty `reasoning_content` on DeepSeek replay targets, injecting the cached reasoning (or the non-Anthropic placeholder) so multi-turn text conversations no longer 400. Fixes #1682. 2 regression tests. - **fix(kiro): route enterprise IAM Identity Center accounts to their regional endpoint** ([#3631] — thanks @artickc): Kiro/CodeWhisperer access tokens and Q Developer profile ARNs are region-bound, so enterprise IAM Identity Center accounts outside `us-east-1` (e.g. `eu-central-1`) were rejected by the default host. Adds `resolveKiroRegion` (stored region → profileArn region → `us-east-1`) and `kiroRuntimeHost` (regional `q.{region}.amazonaws.com`, legacy `codewhisperer.us-east-1` for the default), routes chat + usage to the regional endpoint, and discovers the region-matched `profileArn` via `ListAvailableProfiles` in a best-effort `postExchange` hook. 9 tests. - **fix(combo): skip same-provider/connection targets on connection-level errors** ([#3637] — thanks @herjarsa): on connection-level upstream errors (408/500/502/503/504/524), remaining same-`provider:connection` targets in a combo request are now skipped to avoid hammering a known-bad connection, in both the priority and round-robin paths. Adjusted in review to **exclude OmniRoute circuit-breaker-open responses** (503 + `X-OmniRoute-Provider-Breaker` / `provider_circuit_open`) from this skip, preserving the invariant that a breaker-open is an ordinary target failure (the next same-provider target is still tried). Co-authored with @herjarsa. +- **/v1/responses**: detect stream readiness for tool-call-only and `object`-less chunks so Codex-shaped (reasoning + tools) requests no longer fail with "Stream ended before producing useful content" (#3612) --- diff --git a/open-sse/utils/streamReadiness.ts b/open-sse/utils/streamReadiness.ts index b61d5a6f3b..22cae4ce35 100644 --- a/open-sse/utils/streamReadiness.ts +++ b/open-sse/utils/streamReadiness.ts @@ -104,9 +104,13 @@ function hasOpenAIResponseLifecyclePayload( } function hasChatCompletionToolCallStart(value: unknown): boolean { - const hasToolCallId = (item: unknown) => isRecord(item) && hasNonEmptyString(item.id); - if (Array.isArray(value)) return value.some(hasToolCallId); - return hasToolCallId(value); + // Accept a tool_call item if it has a non-empty id (fully-formed chunk) OR if it + // carries a numeric index (first OpenAI streaming chunk — id/name arrive in later + // chunks). Either form signals that a tool call has started. (#3612) + const hasToolCallStart = (item: unknown) => + isRecord(item) && (hasNonEmptyString(item.id) || typeof item.index === "number"); + if (Array.isArray(value)) return value.some(hasToolCallStart); + return hasToolCallStart(value); } function hasChatCompletionFunctionCallStart(value: unknown): boolean { @@ -114,9 +118,15 @@ function hasChatCompletionFunctionCallStart(value: unknown): boolean { } function hasChatCompletionChunkStartPayload(payload: Record): boolean { - if (payload.object !== "chat.completion.chunk" && payload.type !== "chat.completion.chunk") { - return false; - } + // If object or type is PRESENT and is clearly a non-chat-chunk type, reject early. + // If both are absent (many OA-compatible backends omit object), we fall through and + // let the choices structure decide. (#3612) + const obj = payload.object; + const typ = payload.type; + const hasForeignObject = + (typeof obj === "string" && obj !== "chat.completion.chunk") || + (typeof typ === "string" && typ !== "chat.completion.chunk"); + if (hasForeignObject) return false; const choices = payload.choices; if (!Array.isArray(choices) || choices.length === 0) return false; diff --git a/tests/unit/stream-readiness.test.ts b/tests/unit/stream-readiness.test.ts index bcd36ab671..ebfedf1c11 100644 --- a/tests/unit/stream-readiness.test.ts +++ b/tests/unit/stream-readiness.test.ts @@ -327,6 +327,8 @@ test("hasStreamReadinessSignal accepts structural chat completion chunk starts", ), false ); + // #3612: index-only tool_call chunk (first chunk in OpenAI streaming — no id yet) + // MUST be treated as a readiness signal (tool-call has started) assert.equal( hasStreamReadinessSignal( `data: ${JSON.stringify({ @@ -334,7 +336,7 @@ test("hasStreamReadinessSignal accepts structural chat completion chunk starts", choices: [{ index: 0, delta: { tool_calls: [{ index: 0 }] } }], })}\n\n` ), - false + true ); assert.equal( hasStreamReadinessSignal( @@ -345,6 +347,27 @@ test("hasStreamReadinessSignal accepts structural chat completion chunk starts", ), false ); + // #3612: chunk with valid choices but NO object/type field (some OA-compatible backends + // omit object) — must be accepted as a readiness signal when delta.role is present + assert.equal( + hasStreamReadinessSignal( + `data: ${JSON.stringify({ + id: "chatcmpl-xyz", + choices: [{ index: 0, delta: { role: "assistant" } }], + })}\n\n` + ), + true + ); + // object present but a different (non-chat-chunk) type must still be rejected + assert.equal( + hasStreamReadinessSignal( + `data: ${JSON.stringify({ + object: "chat.completion", + choices: [{ index: 0, delta: { role: "assistant" } }], + })}\n\n` + ), + false + ); }); test("ensureStreamReadiness preserves buffered chunks when stream starts", async () => {