diff --git a/CHANGELOG.md b/CHANGELOG.md index f2ff073a2f..484b46b363 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,7 @@ - fix(antigravity): preserve gemini-3.1-pro High/Low budget tiers (upstream accepts the suffixed ids; stop collapsing to bare gemini-3.1-pro) (#3696) - fix: streaming combos now fail over to the next target when an upstream returns an empty/content-filtered response instead of surfacing a blank reply (#3685) - fix(qwen-web): migrate the Qwen Web provider to the v2 chat API. The legacy `/api/chat/completions` endpoint was retired upstream and returned `504` HTML from Alibaba's gateway for every request regardless of credentials, so no token refresh could help (#3288, discussion #2768). The executor now uses the two-step v2 flow (`/api/v2/chats/new` → `/api/v2/chat/completions?chat_id=`), replays the full browser cookie jar (cna + ssxmod_itna/itna2 + token) required by Alibaba's WAF instead of only a Bearer token, parses the phase-based SSE (think→reasoning, answer→content), and maps the WAF/expired HTML page to a clear actionable auth error instead of surfacing raw HTML. The credential requirement is now a full Cookie header, browser-login capture grabs the WAF cookies, and the model catalog is refreshed to the current upstream ids (`qwen3.7-max`, `qwen3.7-plus`, `qwen3.6-plus`; legacy ids kept as aliases). 17 unit tests. +- fix(responses): `/v1/responses` requests that omit `stream` no longer 502 (`STREAM_EARLY_EOF`) when the upstream returns a valid JSON response. `resolveStreamFlag` now applies the OpenAI Responses API spec default (stream=false when omitted) in addition to the existing Anthropic Messages API default — previously only `sourceFormat=claude` triggered this path, leaving `sourceFormat=openai-responses` to fall through to the wildcard-Accept heuristic (`Accept: */*` → streaming intent), which caused spec-compliant upstreams that return JSON to appear as a dead stream. Codex CLI (always sends `stream: true`) and explicit SSE clients (send `Accept: text/event-stream`) are unaffected. (#3708) - fix(cache): semantic cache responses are now scoped to the requesting API key — two callers with different API keys that send the same prompt and model will no longer receive each other's cached responses (#3740). `generateSignature` includes the `api_key_id` dimension in the SHA-256 hash; unauthenticated requests (no API key) remain isolated from keyed requests. Existing cache entries (generated without the key dimension) are cleared by migration `098`. - **Combo strategy fallback coverage** (`tests/unit/combo-strategy-fallbacks.test.ts`, 11 tests — thanks @diegosouzapw): fill-first / p2c / random / cost-optimized / strict-random fallback paths (previously happy-path only), price-tie stability, stale strict-random deck degradation, unknown-strategy normalization to priority, and circuit-breaker HALF_OPEN recovery inside the combo loop + `preScreenTargets` (lazy-recovery contract). - **`#1731` fast-skip suite restored** (`tests/integration/combo-provider-exhaustion.test.ts` — thanks @diegosouzapw): 5 previously-skipped integration tests rewritten against the current routing policy — 8/8 green. diff --git a/open-sse/utils/aiSdkCompat.ts b/open-sse/utils/aiSdkCompat.ts index 86c4e31461..5d84d1b52e 100644 --- a/open-sse/utils/aiSdkCompat.ts +++ b/open-sse/utils/aiSdkCompat.ts @@ -42,11 +42,10 @@ export function clientWantsJsonResponse(acceptHeader: unknown): boolean { * * Optional `sourceFormat` argument lets callers apply spec-correct defaults * when both `stream` and `Accept` are ambiguous. The Anthropic Messages API - * defaults to non-stream when the body omits `stream`, regardless of Accept - * header. Without this hint, OmniRoute previously routed Anthropic /v1/messages - * requests with a curl-default wildcard Accept header through the streaming - * branch even though upstream returned JSON, producing STREAM_EARLY_EOF / - * HTTP 502 errors. + * and the OpenAI Responses API both default to non-stream when the body omits + * `stream`. Without this hint, OmniRoute previously routed those requests with + * a curl-default wildcard Accept header through the streaming branch even + * though upstream returned JSON, producing STREAM_EARLY_EOF / HTTP 502. */ export function resolveStreamFlag( bodyStream: unknown, @@ -64,11 +63,11 @@ export function resolveStreamFlag( const acceptsEventStream = typeof acceptHeader === "string" && /text\/event-stream/i.test(acceptHeader); - // Anthropic Messages API spec: stream defaults to false when body omits it. - // Only honor an explicit text/event-stream Accept header as a streaming opt-in - // for /v1/messages — otherwise default to non-stream so upstream JSON responses - // are surfaced correctly instead of triggering stream_early_eof. - if (sourceFormat === "claude") { + // Anthropic Messages API and OpenAI Responses API both specify stream=false + // when the body omits `stream`. Honor an explicit text/event-stream Accept + // header as a streaming opt-in; otherwise default to non-stream so + // spec-compliant upstreams that return JSON don't trigger STREAM_EARLY_EOF. + if (sourceFormat === "claude" || sourceFormat === "openai-responses") { if (acceptsEventStream) return true; return false; } diff --git a/tests/unit/t26-ai-sdk-accept-header-compat.test.ts b/tests/unit/t26-ai-sdk-accept-header-compat.test.ts index 0fcc59bb74..08c47b3e78 100644 --- a/tests/unit/t26-ai-sdk-accept-header-compat.test.ts +++ b/tests/unit/t26-ai-sdk-accept-header-compat.test.ts @@ -120,3 +120,26 @@ test("T26: explicit stream aliases resolve true/false correctly", () => { assert.equal(resolveExplicitStreamAlias({ disable_streaming: true }), false); assert.equal(resolveExplicitStreamAlias({}), undefined); }); + +test("T26: sourceFormat=openai-responses applies spec default (stream=false when omitted) (#3708)", () => { + // OpenAI Responses API spec: omitting `stream` means non-streaming, same as claude. + // Previously OmniRoute fell through to the wildcard-Accept heuristic, treating + // Accept: */* as streaming intent → STREAM_EARLY_EOF / 502 on spec-compliant upstreams. + + // Wildcard and undefined Accept must default to non-stream + assert.equal(resolveStreamFlag(undefined, undefined, "openai-responses"), false); + assert.equal(resolveStreamFlag(undefined, "*/*", "openai-responses"), false); + assert.equal(resolveStreamFlag(undefined, "application/json", "openai-responses"), false); + + // Explicit body stream:true still wins + assert.equal(resolveStreamFlag(true, undefined, "openai-responses"), true); + assert.equal(resolveStreamFlag(true, "*/*", "openai-responses"), true); + assert.equal(resolveStreamFlag(false, "text/event-stream", "openai-responses"), false); + + // Accept: text/event-stream is honored as an explicit SSE opt-in + assert.equal(resolveStreamFlag(undefined, "text/event-stream", "openai-responses"), true); + assert.equal( + resolveStreamFlag(undefined, "application/json, text/event-stream", "openai-responses"), + true + ); +});