Files
OmniRoute/tests/unit/gemini-midstream-nonstreaming-responses.test.ts
Markus Hartung 49e0b7d667 fix(responses): escape literal control chars in tool call JSON; emit … (#6786)
* fix(responses): escape literal control chars in tool call JSON; emit status=failed on upstream error #6785

Two bugfixes in the Responses API translator:

1. escapeJsonStringValues() sanitizes tool call arguments containing
   literal 0x0A/0x0D/0x09 bytes (emitted by Gemma4 models) into valid
   JSON \n/\r/\t escapes, preventing SSE framing corruption. Only
   escapes inside JSON string contexts — already-escaped sequences
   and structural JSON pass through unchanged.

2. sendCompleted() checks state.upstreamError and emits status="failed"
   with error.code + error.message instead of silently hardcoding
   status="completed" + error=null, so mid-stream errors (e.g. Gemini
   503 after partial content) are properly surfaced to the client.

3. stream.ts: calls translateResponse(null,...) before controller.error()
   so the translator can emit close events (reasoning item done,
   response.completed) before the stream is terminated.

* test(boundary): fix ESLint no-explicit-any warnings and quality gates

Green the PR against release/v3.8.47 quality gates without weakening tests:

- Replace @typescript-eslint/no-explicit-any in the new boundary/gemma4
  tests with proper interfaces (ResponseBody, ToolDef, ToolArgs, SseEvent
  item accessors) — fixes the "No new ESLint warnings" gate.
- Split tests/unit/translator-resp-openai-responses.test.ts (1079 LOC) by
  extracting the round-trip suite into a sibling file so both stay under
  the 800-line test cap — fixes check:file-size.
- Rename the 5 live boundary tests to *.live.test.ts, gate them behind
  RUN_BOUNDARY_LIVE=1, add a test:boundary:live npm script and register the
  glob in check-test-discovery COLLECTORS — fixes check:test-discovery
  (they hit a live remote and must never run unopted in CI).

Co-authored-by: Markus Hartung <mail@hartmark.se>

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
2026-07-12 02:00:14 -03:00

137 lines
4.3 KiB
TypeScript

/**
* Non-streaming Responses API & Chat Completions mid-stream error handling.
*
* When Gemini returns an error JSON (e.g. 503 UNAVAILABLE) as the non-streaming
* response body, the non-streaming translator must handle it gracefully.
*
* Streaming Responses API mid-stream error coverage is in
* `gemini-midstream-responses.test.ts` (via `translateResponse`).
*/
import test from "node:test";
import assert from "node:assert/strict";
const { translateNonStreamingResponse } =
await import("../../open-sse/handlers/responseTranslator.ts");
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
const ERROR_BODY = {
error: {
code: 503,
message:
"This model is currently experiencing high demand. Spikes in demand are usually temporary. Please try again later.",
status: "UNAVAILABLE",
},
};
const ERROR_BODY_RESOURCE_EXHAUSTED = {
error: {
code: 429,
message: "Resource has been exhausted (e.g. check quota).",
status: "RESOURCE_EXHAUSTED",
},
};
test("Responses API non-streaming: Gemini error returns raw error body (no candidates)", () => {
const result = translateNonStreamingResponse(
ERROR_BODY,
FORMATS.GEMINI,
FORMATS.OPENAI_RESPONSES
);
// The translator has no candidates or promptFeedback to work with
// so it returns the raw error body unchanged. The caller's
// detectMalformedNonStream catches this as `empty_choices`.
assert.equal(result, ERROR_BODY);
});
test("Responses API non-streaming: Gemini error body has no valid output", () => {
const result = translateNonStreamingResponse(
ERROR_BODY,
FORMATS.GEMINI,
FORMATS.OPENAI_RESPONSES
) as Record<string, unknown>;
// No chat.completion shape, no choices, no output array
assert.equal(result.object, undefined);
assert.equal(result.choices, undefined);
assert.ok(result.error, "raw error object should be preserved");
});
test("Chat Completions non-streaming: Gemini error returns raw error body", () => {
const result = translateNonStreamingResponse(ERROR_BODY, FORMATS.GEMINI, FORMATS.OPENAI);
// Same behavior as Responses API: no candidates → pass-through
assert.equal(result, ERROR_BODY);
});
test("Non-streaming: 429 RESOURCE_EXHAUSTED also returns raw error body", () => {
const result = translateNonStreamingResponse(
ERROR_BODY_RESOURCE_EXHAUSTED,
FORMATS.GEMINI,
FORMATS.OPENAI_RESPONSES
) as Record<string, unknown>;
assert.equal(result, ERROR_BODY_RESOURCE_EXHAUSTED);
if (result.error) {
assert.equal((result.error as Record<string, unknown>).code, 429);
assert.equal((result.error as Record<string, unknown>).status, "RESOURCE_EXHAUSTED");
}
});
test("Non-streaming: Antigravity error inside response envelope is passed through", () => {
const agErrorBody = {
response: {
error: { code: 503, message: "overloaded", status: "UNAVAILABLE" },
},
};
const result = translateNonStreamingResponse(
agErrorBody,
FORMATS.GEMINI,
FORMATS.OPENAI_RESPONSES
);
// The response envelope has no candidates → passes through
assert.equal(result, agErrorBody);
});
test("Non-streaming: valid Gemini response with candidates still translates correctly", () => {
const result = translateNonStreamingResponse(
{
responseId: "resp-ok",
modelVersion: "gemini-2.5-flash",
createTime: "2026-04-05T12:00:00.000Z",
candidates: [
{
content: { parts: [{ text: "Hello" }] },
finishReason: "STOP",
},
],
usageMetadata: {
promptTokenCount: 1,
candidatesTokenCount: 1,
totalTokenCount: 2,
},
},
FORMATS.GEMINI,
FORMATS.OPENAI_RESPONSES
) as Record<string, unknown>;
assert.equal(result.object, "chat.completion");
assert.equal((result.choices as unknown[])[0]?.message?.content, "Hello");
});
test("detectMalformedNonStream classifies Gemini error body as empty_choices", async () => {
const { detectMalformedNonStream } = await import("../../open-sse/utils/diagnostics.ts");
// translateNonStreamingResponse returns the raw error body
const raw = translateNonStreamingResponse(ERROR_BODY, FORMATS.GEMINI, FORMATS.OPENAI_RESPONSES);
const diagnosis = detectMalformedNonStream(raw);
assert.equal(
diagnosis,
"empty_choices",
"error body without choices should be classified as empty_choices"
);
});