Files
OmniRoute/tests/unit/openrouter-midstream-error-chunk.test.ts
Markus Hartung ebe086ebb5 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>
2026-07-23 05:14:14 -03:00

86 lines
3.4 KiB
TypeScript

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