Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down
22 changes: 16 additions & 6 deletions open-sse/utils/streamReadiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,19 +104,29 @@ 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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: index should be validated as a non-negative integer

A numeric index is enough to mark a tool-call start, but malformed values like -1 or 0.5 would also pass. Use Number.isInteger(item.index) && item.index >= 0 so only valid OpenAI chunk indices are accepted.

Suggested change
isRecord(item) && (hasNonEmptyString(item.id) || typeof item.index === "number");
isRecord(item) && (hasNonEmptyString(item.id) || (Number.isInteger(item.index) && item.index >= 0));

if (Array.isArray(value)) return value.some(hasToolCallStart);
return hasToolCallStart(value);
}

function hasChatCompletionFunctionCallStart(value: unknown): boolean {
return isRecord(value) && hasNonEmptyString(value.name);
}

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Non-string object/type values are treated as absent

This guard only rejects string mismatches, so object: null or type: 123 would still be accepted if the choices look chat-like. Consider rejecting any non-chat.completion.chunk value for either field.

(typeof typ === "string" && typ !== "chat.completion.chunk");
if (hasForeignObject) return false;

const choices = payload.choices;
if (!Array.isArray(choices) || choices.length === 0) return false;
Expand Down
25 changes: 24 additions & 1 deletion tests/unit/stream-readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,14 +327,16 @@ 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({
object: "chat.completion.chunk",
choices: [{ index: 0, delta: { tool_calls: [{ index: 0 }] } }],
})}\n\n`
),
false
true
);
assert.equal(
hasStreamReadinessSignal(
Expand All @@ -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 () => {
Expand Down