mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
85
tests/unit/openrouter-midstream-error-chunk.test.ts
Normal file
85
tests/unit/openrouter-midstream-error-chunk.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user