Files
OmniRoute/tests/integration/gemini-tool-call-escaping.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

141 lines
5.6 KiB
TypeScript

/**
* tests/integration/gemini-tool-call-escaping.test.ts
*
* Live integration tests verifying tool call argument escaping through
* the Chat Completions endpoint, the Responses API endpoint, and the
* OmniRoute combo routing engine — both streaming and non-streaming.
*
* Gemma4 models emit literal 0x0A bytes in functionCall.args string values.
* OmniRoute's translator must escape these into valid JSON \n sequences.
*
* Environment:
* OMNIROUTE_API_KEY — required (else tests skip)
* OMNIROUTE_URL — defaults to http://localhost:3000
* TEST_DELAY_MS — delay between tests, defaults to 5000
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
skip,
MODEL,
ensureTestEnvironment,
DELAY_BETWEEN_REQUESTS_MS,
ToolCall,
ChatResponse,
ResponsesToolCall,
ResponsesResponse,
TOOL_DEFINITION,
extractToolCalls,
extractToolCallsFromResponses,
validateToolCallArguments,
sendToolCallChatRequest,
sendStreamingToolCallChatRequest,
sendToolCallResponsesRequest,
sendStreamingToolCallResponsesRequest,
} from "./liveGeminiShared.ts";
const DIRECT_MODEL = "gemini/gemma-4-31b-it";
const TOOL_CALL_PROMPT =
"Use write_file to create /tmp/test.py with Python code that has a function with " +
"a for loop that prints hello 5 times. Include proper indentation.";
test.before(async () => {
await ensureTestEnvironment();
});
// ── Gemini Direct Model — Chat Completions ────────────────────────────────
test("gemini direct: tool call arguments are valid JSON", { skip }, async () => {
const data = await sendToolCallChatRequest(DIRECT_MODEL, TOOL_CALL_PROMPT);
const toolCalls = extractToolCalls(data);
validateToolCallArguments(toolCalls);
console.log(` [OK] gemini direct: ${toolCalls.length} tool calls, model=${data.model}`);
});
test("gemini direct: streaming tool call produces valid JSON", { skip }, async () => {
const data = await sendStreamingToolCallChatRequest(DIRECT_MODEL, TOOL_CALL_PROMPT);
const toolCalls = extractToolCalls(data);
if (toolCalls.length === 0) {
console.log(" [skip] gemini direct streaming: model did not produce a tool call");
return;
}
validateToolCallArguments(toolCalls);
console.log(
` [OK] gemini direct streaming: ${toolCalls.length} tool calls, finish=${data.choices[0].finish_reason}`
);
});
// ── Gemini Direct Model — Responses API ───────────────────────────────────
test("gemini direct: responses tool call arguments are valid JSON", { skip }, async () => {
const data = await sendToolCallResponsesRequest(DIRECT_MODEL, TOOL_CALL_PROMPT);
const toolCalls = extractToolCallsFromResponses(data);
if (toolCalls.length === 0) {
console.log(" [skip] gemini direct responses: no tool call in response");
return;
}
validateToolCallArguments(toolCalls);
console.log(
` [OK] gemini direct responses: ${toolCalls.length} tool calls, model=${data.model}`
);
});
test("gemini direct: streaming responses tool call produces valid JSON", { skip }, async () => {
const toolCalls = await sendStreamingToolCallResponsesRequest(DIRECT_MODEL, TOOL_CALL_PROMPT);
if (toolCalls.length === 0) {
console.log(" [skip] gemini direct streaming responses: model did not produce a tool call");
return;
}
validateToolCallArguments(toolCalls);
console.log(` [OK] gemini direct streaming responses: ${toolCalls.length} tool calls`);
});
// ── OmniRoute Combo — Chat Completions ────────────────────────────────────
test("omniroute combo: tool call arguments are valid JSON", { skip }, async () => {
const data = await sendToolCallChatRequest(MODEL, TOOL_CALL_PROMPT);
const toolCalls = extractToolCalls(data);
validateToolCallArguments(toolCalls);
console.log(` [OK] omniroute combo: ${toolCalls.length} tool calls, model=${data.model}`);
});
test("omniroute combo: streaming tool call produces valid JSON", { skip }, async () => {
const data = await sendStreamingToolCallChatRequest(MODEL, TOOL_CALL_PROMPT);
const toolCalls = extractToolCalls(data);
if (toolCalls.length === 0) {
console.log(" [skip] omniroute combo streaming: model did not produce a tool call");
return;
}
validateToolCallArguments(toolCalls);
console.log(
` [OK] omniroute combo streaming: ${toolCalls.length} tool calls, finish=${data.choices[0].finish_reason}`
);
});
// ── OmniRoute Combo — Responses API ───────────────────────────────────────
test("omniroute combo: responses tool call arguments are valid JSON", { skip }, async () => {
const data = await sendToolCallResponsesRequest(MODEL, TOOL_CALL_PROMPT);
const toolCalls = extractToolCallsFromResponses(data);
if (toolCalls.length === 0) {
console.log(" [skip] omniroute combo responses: no tool call in response");
return;
}
validateToolCallArguments(toolCalls);
console.log(
` [OK] omniroute combo responses: ${toolCalls.length} tool calls, model=${data.model}`
);
});
test("omniroute combo: streaming responses tool call produces valid JSON", { skip }, async () => {
const toolCalls = await sendStreamingToolCallResponsesRequest(MODEL, TOOL_CALL_PROMPT);
if (toolCalls.length === 0) {
console.log(" [skip] omniroute combo streaming responses: model did not produce a tool call");
return;
}
validateToolCallArguments(toolCalls);
console.log(` [OK] omniroute combo streaming responses: ${toolCalls.length} tool calls`);
});