mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 04:42:10 +03:00
* 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>
167 lines
4.8 KiB
TypeScript
167 lines
4.8 KiB
TypeScript
/**
|
|
* Regression test: Gemini mid-stream 503 error translated through the
|
|
* Responses-API pipeline must emit a proper `response.completed` with
|
|
* `status: "failed"` and close the reasoning item, instead of silently
|
|
* aborting the stream.
|
|
*
|
|
* The event sequence from the Gemini SSE stream was:
|
|
* 1. thought chunk (reasoning content)
|
|
* 2. 503 error chunk
|
|
* 3. provider closes connection (flush)
|
|
*/
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
const { translateResponse, initState } =
|
|
await import("../../open-sse/translator/index.ts");
|
|
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
|
|
|
const THOUGHT_TEXT = "The user wants me to execute a cron job named `vibe-check`";
|
|
|
|
const THOUGHT_CHUNK = {
|
|
responseId: "faxOavr4K52qxN8PntP3mAY",
|
|
modelVersion: "gemma-4-31b-it",
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [{ text: THOUGHT_TEXT, thought: true }],
|
|
role: "model",
|
|
},
|
|
index: 0,
|
|
},
|
|
],
|
|
usageMetadata: {
|
|
promptTokenCount: 22865,
|
|
totalTokenCount: 22879,
|
|
promptTokensDetails: [{ modality: "TEXT", tokenCount: 22865 }],
|
|
thoughtsTokenCount: 14,
|
|
serviceTier: "standard",
|
|
},
|
|
};
|
|
|
|
const ERROR_CHUNK = {
|
|
error: {
|
|
code: 503,
|
|
message:
|
|
"This model is currently experiencing high demand. Spikes in demand are usually temporary. Please try again later.",
|
|
status: "UNAVAILABLE",
|
|
},
|
|
};
|
|
|
|
test("mid-stream 503 error -> response.completed with status='failed'", () => {
|
|
const state = initState(FORMATS.OPENAI_RESPONSES);
|
|
|
|
// ── Step 1: Send thought chunk ──
|
|
const thoughtEvents = translateResponse(
|
|
FORMATS.GEMINI,
|
|
FORMATS.OPENAI_RESPONSES,
|
|
THOUGHT_CHUNK,
|
|
state
|
|
);
|
|
|
|
assert.ok(
|
|
thoughtEvents?.length > 0,
|
|
"thought chunk should produce Responses API events"
|
|
);
|
|
|
|
// Verify reasoning was started
|
|
const reasoningItemAdded = thoughtEvents.find(
|
|
(e) => e?.data?.type === "response.output_item.added" && e.data.item?.type === "reasoning"
|
|
);
|
|
assert.ok(reasoningItemAdded, "should emit response.output_item.added for reasoning");
|
|
|
|
const reasoningDelta = thoughtEvents.find(
|
|
(e) => e?.data?.type === "response.reasoning_summary_text.delta"
|
|
);
|
|
assert.ok(reasoningDelta, "should emit reasoning delta");
|
|
assert.equal(reasoningDelta.data.delta, THOUGHT_TEXT);
|
|
|
|
// ── Step 2: Send 503 error chunk ──
|
|
const errorEvents = translateResponse(
|
|
FORMATS.GEMINI,
|
|
FORMATS.OPENAI_RESPONSES,
|
|
ERROR_CHUNK,
|
|
state
|
|
);
|
|
|
|
// Error chunk itself should produce no events
|
|
assert.equal(errorEvents?.length ?? 0, 0, "error chunk should produce no events");
|
|
|
|
// But must be recorded in state
|
|
assert.ok(state.upstreamError, "state.upstreamError should be set");
|
|
assert.equal(state.upstreamError.status, 503);
|
|
|
|
// ── Step 3: Flush stream ──
|
|
const flushEvents = translateResponse(
|
|
FORMATS.GEMINI,
|
|
FORMATS.OPENAI_RESPONSES,
|
|
null,
|
|
state
|
|
);
|
|
|
|
assert.ok(flushEvents?.length > 0, "flush should produce events");
|
|
|
|
// The reasoning item should be properly closed
|
|
const reasoningDone = flushEvents.find(
|
|
(e) => e?.data?.type === "response.output_item.done"
|
|
);
|
|
assert.ok(reasoningDone, "flush should emit response.output_item.done for reasoning");
|
|
assert.equal(
|
|
reasoningDone.data.item?.type,
|
|
"reasoning",
|
|
"done item should be type 'reasoning'"
|
|
);
|
|
|
|
// The response should be completed with status 'failed' and error info
|
|
const completedEvent = flushEvents.find(
|
|
(e) => e?.data?.type === "response.completed"
|
|
);
|
|
assert.ok(completedEvent, "flush should emit response.completed");
|
|
assert.equal(
|
|
completedEvent.data.response.status,
|
|
"failed",
|
|
"response should have status 'failed' when upstreamError is set"
|
|
);
|
|
assert.ok(
|
|
completedEvent.data.response.error,
|
|
"response.error should be present when upstreamError is set"
|
|
);
|
|
assert.ok(
|
|
completedEvent.data.response.error?.code,
|
|
"response.error.code should be truthy"
|
|
);
|
|
assert.match(
|
|
completedEvent.data.response.error?.message || "",
|
|
/high demand/
|
|
);
|
|
|
|
// ── Step 4: Verify the reverse — no upstreamError = status "completed" ──
|
|
const cleanState = initState(FORMATS.OPENAI_RESPONSES);
|
|
|
|
// Send thought chunk
|
|
translateResponse(FORMATS.GEMINI, FORMATS.OPENAI_RESPONSES, THOUGHT_CHUNK, cleanState);
|
|
|
|
// Flush without error
|
|
const cleanFlush = translateResponse(
|
|
FORMATS.GEMINI,
|
|
FORMATS.OPENAI_RESPONSES,
|
|
null,
|
|
cleanState
|
|
);
|
|
|
|
const cleanCompleted = cleanFlush.find(
|
|
(e) => e?.data?.type === "response.completed"
|
|
);
|
|
assert.ok(cleanCompleted, "clean flush should emit response.completed");
|
|
assert.equal(
|
|
cleanCompleted.data.response.status,
|
|
"completed",
|
|
"clean response should have status 'completed'"
|
|
);
|
|
assert.equal(
|
|
cleanCompleted.data.response.error,
|
|
null,
|
|
"clean response should have error: null"
|
|
);
|
|
});
|