fix(sse): surface OpenRouter mid-stream error chunks instead of a false empty success (#8210)

* fix(sse): surface OpenRouter mid-stream error chunks instead of a false empty success

OpenRouter (and similar OpenAI-compatible aggregators) can send an HTTP 200
SSE stream whose body carries a chat.completion.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. The Responses-API
response translator's `!chunk.choices?.length` branch treated this exactly
like a legitimate trailing-usage/no-op chunk, so the stream silently ended
with response.completed / error: null / output: [] -- a false "successful
but empty" response masking a real 502 provider_unavailable failure.

Live repro: request 1784726796287-a45bb3 (OpenClaw via the default combo,
nvidia/nemotron-3-ultra-550b-a55b:free via OpenRouter) got HTTP 200 with 0
tokens in/out after Nvidia returned "Worker local total request limit
reached (33/32)" mid-stream; the client saw an empty completed response
with no indication anything failed.

Mirrors the Gemini mid-stream error fix (#4177): set state.upstreamError
from the in-band error chunk so stream.ts's existing upstreamError handling
takes over (sendCompleted emits status: "failed" with a real error object).

Co-Authored-By: Markus Hartung <markus.hartream@gmail.com>

* chore(quality): file-size baseline for own-growth (#8210)

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Markus Hartung <markus.hartream@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Markus Hartung
2026-07-23 10:14:14 +02:00
committed by GitHub
parent 5d764a40ee
commit ebe086ebb5
3 changed files with 113 additions and 1 deletions

View File

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

View File

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

View File

@@ -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");
});