diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 9fcd6f0aad..279335935e 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -217,7 +217,8 @@ "_rebaseline_2026_07_22_8211_gemini_malformed_tool_choice": "PR #8211 (hartmark, fix/gemini-malformed-function-call-tool-choice) own growth: open-sse/translator/response/gemini-to-openai.ts 771->821 (+50, entirely this PR's diff — no other commit touched this file between the PR's merge-base and the release tip). Adds MALFORMED_FUNCTION_CALL/UNEXPECTED_TOOL_CALL handling inside geminiToOpenAIResponse(): synthesizes a `malformed_tool_call` tool_calls entry so finish_reason normalizes to the standard \"tool_calls\" instead of an unrecognized raw enum value that OpenAI-compatible clients (e.g. OpenClaw) silently ignore, and always synthesizes (rather than skipping when a real tool call already exists) so a malformed attempt alongside a real one in the same turn is not silently discarded. Irreducible cohesive addition at the existing candidate/finishReason translation chokepoint (mirrors the 9router#2462 raw-finish-reason precedent immediately below it in the same function). Covered by the PR's own tests/unit test additions for both the malformed-only and malformed-plus-real-call cases.", "open-sse/translator/response/gemini-to-openai.ts": 821, "_rebaseline_2026_07_22_7936_namespace_roundtrip": "#7936 (@RCrushMe, Responses-Chat namespace round-trip identity seam) own growth: open-sse/translator/response/openai-responses.ts 1092->1125 (+33) and open-sse/utils/stream.ts 2814->2869 (+55) — threading the namespace-identity seam through the Responses↔Chat translation + stream paths so tool-call namespaces survive the round-trip. Cohesive translation/stream wiring at existing chokepoints, frozen at new size.", - "open-sse/translator/response/openai-responses.ts": 1137, + "_rebaseline_2026_07_22_8210_openrouter_midstream_error": "PR #8210 (hartmark, fix/openrouter-midstream-error-surfacing) own growth: open-sse/translator/response/openai-responses.ts 1137->1163 (+26) measured on the merged tip (release 1137 + this PR own growth). Adds a single new branch inside openaiToOpenAIResponsesResponse() that detects an OpenRouter-style mid-stream aggregator error (HTTP 200 SSE chunk with empty choices + a top-level error object) and surfaces it as state.upstreamError instead of silently falling through to the no-op/awaitingTrailingUsage path, which previously masked the failure as a false empty-success completion and skipped combo fallback. Irreducible call-site addition at the existing chunk-dispatch chokepoint (mirrors the Gemini-to-OpenAI translator's #4177 precedent for the same class of upstream error surfacing). Note: this baseline entry does NOT cover the separate pre-existing +11 drift already on the release tip from #8081/#8162 (1125->1136, unrelated reasoning-placeholder-stripping fix merged after this PR branched) — that drift belongs to the maintainer's rebaseline, not this PR.", + "open-sse/translator/response/openai-responses.ts": 1163, "open-sse/utils/cursorAgentProtobuf.ts": 1521, "_rebaseline_2026_07_23_8143_empty_catch_logging": "#8143 (@chirag127) own growth: open-sse/utils/stream.ts 2869->2887 (+18) — replacing empty catch blocks in the SSE stream subsystem with console.debug logging (Rule #6 silent-swallow fix, issues #8138-#8142). Cohesive logging additions at the existing catch chokepoints, not extractable; frozen at new size. Covered by tests/unit/stream-handler-catch-logging-8143.test.ts.", "open-sse/utils/stream.ts": 2887, diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 2371db9adc..f649853552 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -105,6 +105,32 @@ export function openaiToOpenAIResponsesResponse(chunk, state) { } if (!chunk.choices?.length) { + // Mid-stream aggregator error: OpenRouter (and similar OpenAI-compatible + // aggregators) can send an HTTP 200 SSE stream whose body carries a chunk with + // empty `choices` and a top-level `error` object instead of any delta — e.g. the + // underlying provider hitting its own capacity limit mid-request. Without this + // branch the chunk has no choices, so it falls into the awaitingTrailingUsage/ + // no-op path below and the stream silently ends with a false "completed, empty + // output" response, masking the failure and skipping combo fallback. Surface it + // as state.upstreamError so stream.ts errors the stream out (mirrors the + // Gemini-to-OpenAI translator's #4177 fix). + if (chunk.error && typeof chunk.error === "object") { + const rawCode = chunk.error.code; + const status = + typeof rawCode === "number" && rawCode >= 400 && rawCode <= 599 ? rawCode : 502; + state.upstreamError = { + status, + type: status === 429 ? "rate_limit_error" : "server_error", + code: + typeof chunk.error.metadata?.error_type === "string" + ? chunk.error.metadata.error_type + : status === 429 + ? "rate_limit_exceeded" + : "bad_gateway", + message: typeof chunk.error.message === "string" ? chunk.error.message : "Upstream failure", + }; + return []; + } // #6906: a deferred finish_reason (awaitingTrailingUsage, see below) completes here — // the trailing usage-only chunk (choices: [], usage: {...}) is what real // stream_options.include_usage=true upstreams send after finish_reason (see the diff --git a/tests/unit/openrouter-midstream-error-chunk.test.ts b/tests/unit/openrouter-midstream-error-chunk.test.ts new file mode 100644 index 0000000000..98f2bd960c --- /dev/null +++ b/tests/unit/openrouter-midstream-error-chunk.test.ts @@ -0,0 +1,85 @@ +/** + * Regression test: OpenRouter mid-stream error chunk silently swallowed. + * + * OpenRouter (and other OpenAI-compatible aggregators) can send an HTTP 200 SSE + * stream whose body carries a `chat.completion.chunk` with an empty `choices` + * array and a top-level `error` object instead of any delta — e.g. when the + * underlying provider (Nvidia, hosting a free model) hits its own capacity limit + * mid-request: + * {"choices":[],"error":{"code":502,"message":"Upstream error from Nvidia: ... + * Worker local total request limit reached (33/32)","metadata":{"error_type": + * "provider_unavailable"}}} + * + * Before this fix, `openaiToOpenAIResponsesResponse()`'s `!chunk.choices?.length` + * branch treated this exactly like a legitimate trailing-usage/no-op chunk and + * dropped it, so the stream ended with `response.completed` / `error: null` / + * `output: []` — a false "successful but empty" response instead of surfacing + * the real 502 upstream failure. Mirrors the Gemini mid-stream error fix (#4177). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiToOpenAIResponsesResponse } = + await import("../../open-sse/translator/response/openai-responses.ts"); +const { initState } = await import("../../open-sse/translator/index.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); + +test("OpenRouter mid-stream 502 provider_unavailable is surfaced as upstreamError, not dropped", () => { + const state = initState(FORMATS.OPENAI_RESPONSES); + + const errorChunk = { + id: "gen-1784726796-AJw13XXRnwqcRL2kieNm", + object: "chat.completion.chunk", + created: 1784726796, + model: "nvidia/nemotron-3-ultra-550b-a55b:free", + provider: "Nvidia", + choices: [], + error: { + code: 502, + message: + "Upstream error from Nvidia: ResourceExhausted: Worker local total request limit reached (33/32)", + metadata: { error_type: "provider_unavailable" }, + }, + }; + + const immediateEvents = openaiToOpenAIResponsesResponse(errorChunk, state); + assert.deepEqual(immediateEvents, [], "the error chunk itself emits no delta events"); + + // End of stream (chunk === null) flushes the deferred completion. + const flushEvents = openaiToOpenAIResponsesResponse(null, state); + const completedEvent = flushEvents.find((e) => e.event === "response.completed"); + assert.ok(completedEvent, "should have a response.completed event"); + assert.equal( + completedEvent.data.response.status, + "failed", + "status must be 'failed', not a false 'completed'" + ); + assert.ok(completedEvent.data.response.error, "error must not be null"); + assert.match( + completedEvent.data.response.error.message, + /Worker local total request limit reached/ + ); + assert.equal(completedEvent.data.response.output.length, 0); +}); + +test("OpenRouter mid-stream error with a rate-limit code maps to a 429 upstreamError", () => { + const state = initState(FORMATS.OPENAI_RESPONSES); + + openaiToOpenAIResponsesResponse( + { + choices: [], + error: { + code: 429, + message: "Rate limit exceeded, please try again later.", + metadata: { error_type: "rate_limited" }, + }, + }, + state + ); + + const flushEvents = openaiToOpenAIResponsesResponse(null, state); + const completedEvent = flushEvents.find((e) => e.event === "response.completed"); + assert.ok(completedEvent); + assert.equal(completedEvent.data.response.status, "failed"); + assert.equal(completedEvent.data.response.error.code, "429"); +});