fix(responses): detect stream readiness for tool-call-only and object-less chunks (#3612) (#3661)

Closes #3612
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-11 12:53:07 -03:00
committed by GitHub
parent 60e38a71d1
commit 3ce157d22e
3 changed files with 41 additions and 7 deletions

View File

@@ -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)
---

View File

@@ -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<string, unknown>): 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;

View File

@@ -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 () => {