Files
OmniRoute/tests/integration/gemini-malformed-function-call-stream.test.ts
Markus Hartung 4ea08f520b fix(sse): Gemini malformed function-call handling + tool_choice translation (#8211)
* fix(sse): synthesize tool_calls for Gemini's malformed function-call abort reasons

Live incident (dashboard log id 1784489701456-d8c0e9): Gemini terminates a
stream with finishReason MALFORMED_FUNCTION_CALL/UNEXPECTED_TOOL_CALL when its
own parser rejects an attempted tool call — there's no real functionCall part,
only a human-readable finishMessage. gemini-to-openai.ts passed this through
raw as finish_reason (9router#2462's fix, correctly keeping it off a clean
"stop"/Claude end_turn), but a raw "malformed_function_call" isn't one of
OpenAI's 5 documented finish_reason values, so a real OpenAI-format client
(OpenClaw) has no handling for it at all and silently never notices the turn
failed — confirmed live via tests/integration/live-gemini-workload.test.ts's
[28] streaming case after the Gemini TPM/rebase work on this branch.

Fix: synthesize a tool_calls entry (arguments carry the error code + Gemini's
finishMessage, valid JSON) and finish_reason: "tool_calls" instead, routing
the failure into the ordinary "tool call arguments didn't parse" path every
OpenAI-compatible agent loop already handles. Defers to a real tool call if
one already completed earlier in the same turn — the real call wins, no
synthetic entry piles on top of it.

Tests (TDD, each confirmed red-before-green):
- 5 new unit tests in the existing 9router#2462 regression file, covering the
  synthesis itself, UNEXPECTED_TOOL_CALL, the real-tool-call-wins edge case,
  and no-regression on a clean STOP.
- New fixture (tests/fixtures/translation/gemini-malformed-function-call-stream.json):
  the real 6-chunk event series from the live incident, sanitized (personal
  paths/URLs replaced with generic placeholders, structure preserved exactly).
- New integration test chains the real translator into the real Responses API
  transformer using that same fixture, proving correct behavior on BOTH
  /v1/chat/completions and /v1/responses from one shared ground-truth event
  series.

Co-authored-by: Markus Hartung <markus.hartung@gmail.com>

* fix(sse): don't drop a malformed tool-call failure when it lands beside a real one

Live incident (dashboard log id 1784589106014-2a42f8), analyzing why the
prior malformed-function-call fix (3568c7259) still wasn't reaching the
client in this case: Gemini can emit a REAL, valid functionCall AND finish
the SAME candidate with MALFORMED_FUNCTION_CALL — the model attempted
multiple tool calls in one turn (here: a real status-check call plus a
malformed "exec"+"cron" multi-call attempt), one parsed cleanly and the
other didn't.

The first fix version skipped synthesizing a failure signal whenever a real
tool call already existed (state.toolCalls.size > 0), on the assumption
that meant the model was retrying a LATER, separate attempt after an
earlier one already succeeded. That's indistinguishable, from the
translator's state, from this same-turn case — so it silently discarded
the malformed attempt's information entirely: the client saw the real call
succeed and never learned the other tool calls were attempted and rejected.

Fix: always synthesize the failure entry when a malformed abort reason is
seen, appending it alongside any real tool call rather than skipping it.
Multiple tool_calls in one response is normal, well-supported OpenAI
behavior (parallel tool calls), so this adds the failure as an additional
entry instead of replacing or hiding the real one.

Tests (TDD, confirmed red-before-green):
- Rewrote the unit test that encoded the old (wrong) assumption to assert
  both the real and synthesized calls are present.
- New fixture (gemini-malformed-function-call-parallel-real-call-stream.json):
  the real event series from this incident, sanitized.
- New integration tests (same file as 3568c7259's) prove both
  /v1/chat/completions and /v1/responses surface both tool calls correctly
  from this fixture.

Co-authored-by: Markus Hartung <markus.hartung@gmail.com>

* feat(sse): honor tool_choice when translating OpenAI requests to Gemini

Investigating a live report that gemini-3.1-flash-lite frequently narrates
an intended tool call in plain text instead of actually emitting one
(dashboard log id 1784591483850-49c408 — 9 raw provider chunks, all plain
text, zero functionCall parts, clean finishReason STOP): body.tool_choice
was never read anywhere in the OpenAI->Gemini request translator.
result.toolConfig was unconditionally hardcoded to
{ functionCallingConfig: { mode: "VALIDATED" } } whenever tools were
present, regardless of what the caller sent. VALIDATED lets the model
respond with plain text OR a schema-validated function call at its own
discretion — it never forces a call the way OpenAI's tool_choice:
"required" (Gemini's ANY mode) does, so a caller had no way to compel a
tool call even when explicitly requesting one.

Added convertOpenAIToolChoiceToGemini(), mirroring the existing
convertOpenAIToolChoice() in openai-to-claude.ts for the same OpenAI
tool_choice shapes (string "auto"/"none"/"required", or
{type:"function",function:{name}} to force one specific tool):
  - unset/"auto"        -> VALIDATED (unchanged default, no regression)
  - "required"/"any"    -> ANY (forces a call)
  - "none"              -> NONE (disables function calling)
  - {type:"function",...} -> ANY + allowedFunctionNames: [name]

Wired into both Gemini request paths: the direct/base translator
(openaiToGeminiBase) and the Antigravity/Cloud Code envelope
(wrapInCloudCodeEnvelope), which now reuses the base translator's already-
computed toolConfig instead of re-deriving its own hardcoded VALIDATED.

This unblocks (but does not itself resolve) the live question — a
tool_choice: "required" A/B test against gemini-3.1-flash-lite follows to
confirm ANY mode actually changes the narrate-vs-act behavior in practice.

Also updates the T11 any-budget allowlist for this file: the "any" string
comparisons (tool_choice value "any", not a TypeScript type) are the same
documented false-positive pattern already carved out for executors/base.ts.

Tests (TDD, confirmed red-before-green): 9 new unit tests covering all
tool_choice shapes on both the direct and Antigravity/Cloud Code paths,
plus the no-tools and unset-default no-regression cases.

Co-authored-by: Markus Hartung <markus.hartung@gmail.com>

* chore(quality): file-size baseline for own-growth (#8211)

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Markus Hartung <markus.hartung@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 05:03:44 -03:00

283 lines
12 KiB
TypeScript

/**
* tests/integration/gemini-malformed-function-call-stream.test.ts
*
* Chains the real Gemini -> OpenAI translator (open-sse/translator/response/
* gemini-to-openai.ts) with the real Responses API transformer (open-sse/
* transformer/responsesTransformer.ts) against the exact (sanitized) event
* series captured from a live incident — see tests/fixtures/translation/
* gemini-malformed-function-call-stream.json for the full sanitized fixture
* and incident description (dashboard log id 1784489701456-d8c0e9).
*
* Gemini's own parser rejected an attempted tool call mid-turn and terminated
* the stream with finishReason: MALFORMED_FUNCTION_CALL — a value that isn't
* one of OpenAI's 5 documented finish_reason values (stop, length, tool_calls,
* content_filter, function_call). Passed through raw, a real OpenAI-format
* client (OpenClaw) has no handling for it at all and silently never notices
* the turn failed. The fix (open-sse/translator/response/gemini-to-openai.ts)
* synthesizes a tool_calls entry and finish_reason: "tool_calls" instead,
* routing the failure into the ordinary "tool call arguments didn't parse"
* path every OpenAI-compatible agent loop already handles.
*
* This test proves the fix holds for BOTH client-facing surfaces that share
* this same translator output: Chat Completions (consumes the translator's
* chunks directly) and the Responses API (consumes the same chunks through
* the transformer chain) — using the identical real event series for both,
* so a regression in either surface is caught against the same ground truth.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const { geminiToOpenAIResponse } =
await import("../../open-sse/translator/response/gemini-to-openai.ts");
const { createResponsesApiTransformStream } =
await import("../../open-sse/transformer/responsesTransformer.ts");
const FIXTURES_DIR = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../fixtures/translation"
);
const FIXTURE_PATH = path.join(FIXTURES_DIR, "gemini-malformed-function-call-stream.json");
// Follow-up live incident (log id 1784589106014-2a42f8): a REAL functionCall and a
// MALFORMED_FUNCTION_CALL finish arriving in the SAME turn — see the fixture's own
// "description" field for the full incident writeup.
const PARALLEL_FIXTURE_PATH = path.join(
FIXTURES_DIR,
"gemini-malformed-function-call-parallel-real-call-stream.json"
);
type GeminiChunk = Record<string, unknown>;
type OpenAIChunk = {
choices: Array<{
delta: {
reasoning_content?: string;
tool_calls?: Array<{
id: string;
type: string;
function: { name: string; arguments: string };
}>;
};
finish_reason: string | null;
}>;
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
};
type ResponsesFunctionCallItem = {
type: "function_call";
call_id: string;
name: string;
arguments: string;
};
type ResponsesEventData = { type?: string; item?: ResponsesFunctionCallItem };
function asResponsesEventData(data: unknown): ResponsesEventData | null {
return data && typeof data === "object" ? (data as ResponsesEventData) : null;
}
function loadFixtureChunks(fixturePath: string): GeminiChunk[] {
const fixture = JSON.parse(fs.readFileSync(fixturePath, "utf8"));
assert.ok(Array.isArray(fixture.chunks) && fixture.chunks.length > 0, "fixture must have chunks");
return fixture.chunks;
}
/** Real Gemini -> OpenAI translation, exactly as the live streaming pipeline runs it. */
function translateFixtureToOpenAIChunks(fixturePath: string = FIXTURE_PATH): OpenAIChunk[] {
const state: Record<string, unknown> = {
functionIndex: 0,
toolCalls: new Map(),
messageId: "fixture-msg",
model: "gemini-3.1-flash-lite",
};
const openaiChunks: OpenAIChunk[] = [];
for (const chunk of loadFixtureChunks(fixturePath)) {
const results = geminiToOpenAIResponse(chunk, state);
if (results) openaiChunks.push(...(results as OpenAIChunk[]));
}
return openaiChunks;
}
/** Feeds already-translated OpenAI chat chunks through the real Responses API transformer. */
async function transformToResponsesEvents(
openaiChunks: OpenAIChunk[]
): Promise<Array<{ event: string | null; data: unknown }>> {
const stream = createResponsesApiTransformStream();
const writer = stream.writable.getWriter();
const reader = stream.readable.getReader();
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const output: string[] = [];
const readerTask = (async () => {
while (true) {
const { value, done } = await reader.read();
if (done) break;
output.push(decoder.decode(value));
}
})();
for (const chunk of openaiChunks) {
await writer.write(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
}
await writer.close();
await readerTask;
return output
.join("")
.trim()
.split("\n\n")
.filter(Boolean)
.map((entry) => {
const lines = entry.split("\n");
const eventLine = lines.find((l) => l.startsWith("event: "));
const dataLine = lines.find((l) => l.startsWith("data: "));
const rawData = dataLine ? dataLine.slice("data: ".length) : null;
return {
event: eventLine ? eventLine.slice("event: ".length) : null,
data: rawData && rawData !== "[DONE]" ? JSON.parse(rawData) : rawData,
};
});
}
// ── Chat Completions surface ────────────────────────────────────────────────
test("Chat Completions: malformed-tool-call stream ends in a client-usable tool_calls, not a raw error string", () => {
const openaiChunks = translateFixtureToOpenAIChunks();
assert.ok(openaiChunks.length > 0, "expected at least one translated chunk");
// The reasoning ("thought") content from the live incident's planning steps
// must still come through untouched — this fix only changes the terminal
// finish_reason/tool_calls handling, not the reasoning stream.
const reasoningDeltas = openaiChunks
.map((c) => c.choices?.[0]?.delta?.reasoning_content)
.filter((v): v is string => typeof v === "string" && v.length > 0);
assert.ok(reasoningDeltas.length >= 3, "expected the 3 planning/thought deltas to survive");
assert.match(reasoningDeltas[0], /Initiating the Workflow/);
const toolCallChunk = openaiChunks.find((c) => c.choices?.[0]?.delta?.tool_calls);
assert.ok(toolCallChunk, "expected a synthesized tool_calls delta chunk");
const toolCall = toolCallChunk!.choices[0].delta.tool_calls[0];
assert.equal(toolCall.type, "function");
assert.equal(typeof toolCall.id, "string");
const parsedArgs = JSON.parse(toolCall.function.arguments);
assert.equal(parsedArgs.error, "MALFORMED_FUNCTION_CALL");
assert.match(parsedArgs.message, /Malformed function call/);
// Sanitized fixture content, not the real incident's personal paths/URLs.
assert.doesNotMatch(parsedArgs.message, /\/home\/ping\//);
assert.doesNotMatch(parsedArgs.message, /academicwork|senterprise|hiq\.se/);
const finalChunk = openaiChunks.at(-1)!;
assert.equal(
finalChunk.choices[0].finish_reason,
"tool_calls",
"finish_reason must be the standard OpenAI value a real client can act on"
);
// Never leak the raw Gemini string as the terminal signal.
assert.notEqual(finalChunk.choices[0].finish_reason, "malformed_function_call");
// Usage from the fixture's final chunk must still flow through untouched.
assert.equal(finalChunk.usage?.prompt_tokens, 24512);
assert.ok((finalChunk.usage?.completion_tokens ?? 0) > 0);
});
// ── Responses API surface (same fixture, chained through the transformer) ──
test("Responses API: malformed-tool-call stream ends in a real function_call output item, not response.failed", async () => {
const openaiChunks = translateFixtureToOpenAIChunks();
const events = await transformToResponsesEvents(openaiChunks);
const eventTypes = events.map((e) => e.event).filter(Boolean);
assert.ok(eventTypes.includes("response.output_item.added"), "expected an output item to open");
const functionCallDone = events.find(
(e) =>
e.event === "response.output_item.done" &&
asResponsesEventData(e.data)?.item?.type === "function_call"
);
assert.ok(functionCallDone, "expected a completed function_call output item");
const funcItem = asResponsesEventData(functionCallDone!.data)!.item!;
assert.equal(typeof funcItem.call_id, "string");
const parsedArgs = JSON.parse(funcItem.arguments);
assert.equal(parsedArgs.error, "MALFORMED_FUNCTION_CALL");
const completed = events.find((e) => e.event === "response.completed");
assert.ok(completed, "expected a normal response.completed terminal event, not an error");
assert.notEqual(asResponsesEventData(completed!.data)?.type, "response.failed");
// The transformer must never surface this as an in-band error event either —
// that would be the same class of "client sees nothing actionable" failure
// this whole fix exists to prevent, just on the Responses API side.
assert.ok(
!eventTypes.includes("error") && !eventTypes.includes("response.failed"),
`expected no error-shaped event, got: ${eventTypes.join(", ")}`
);
const doneMarker = events.at(-1);
assert.equal(doneMarker?.data, "[DONE]");
});
test("both surfaces agree: exactly one synthesized tool call, sourced from the same translated chunks", async () => {
const openaiChunks = translateFixtureToOpenAIChunks();
const toolCallChunks = openaiChunks.filter((c) => c.choices?.[0]?.delta?.tool_calls);
assert.equal(toolCallChunks.length, 1, "expected exactly one synthesized tool_calls chunk");
const events = await transformToResponsesEvents(openaiChunks);
const functionCallItems = events.filter(
(e) =>
e.event === "response.output_item.done" &&
asResponsesEventData(e.data)?.item?.type === "function_call"
);
assert.equal(functionCallItems.length, 1, "expected exactly one function_call output item");
});
// ── Second fixture: a REAL tool call arriving in the SAME turn as a malformed one ──
//
// See tests/fixtures/translation/gemini-malformed-function-call-parallel-real-call-stream.json
// for the full incident writeup (log id 1784589106014-2a42f8). This is the case the
// first version of the fix got wrong: it skipped synthesizing anything whenever a real
// tool call already existed, silently discarding the malformed attempt's information.
// Both the real call and the synthesized failure must reach the client.
test("Chat Completions: a real tool call alongside a malformed one keeps BOTH — the failure is not silently dropped", () => {
const openaiChunks = translateFixtureToOpenAIChunks(PARALLEL_FIXTURE_PATH);
const toolCallChunks = openaiChunks.filter((c) => c.choices?.[0]?.delta?.tool_calls);
assert.equal(toolCallChunks.length, 2, "expected the real call AND the synthesized failure");
const realCall = toolCallChunks[0].choices[0].delta.tool_calls![0];
assert.equal(realCall.function.name, "check_status");
const syntheticCall = toolCallChunks[1].choices[0].delta.tool_calls![0];
assert.equal(syntheticCall.function.name, "malformed_tool_call");
const parsedArgs = JSON.parse(syntheticCall.function.arguments);
assert.equal(parsedArgs.error, "MALFORMED_FUNCTION_CALL");
assert.match(parsedArgs.message, /exec/);
const finalChunk = openaiChunks.at(-1)!;
assert.equal(finalChunk.choices[0].finish_reason, "tool_calls");
});
test("Responses API: a real tool call alongside a malformed one produces TWO function_call output items", async () => {
const openaiChunks = translateFixtureToOpenAIChunks(PARALLEL_FIXTURE_PATH);
const events = await transformToResponsesEvents(openaiChunks);
const functionCallItems = events
.filter(
(e) =>
e.event === "response.output_item.done" &&
asResponsesEventData(e.data)?.item?.type === "function_call"
)
.map((e) => asResponsesEventData(e.data)!.item!);
assert.equal(
functionCallItems.length,
2,
"expected both the real and synthesized function_call items"
);
assert.ok(functionCallItems.some((item) => item.name === "check_status"));
assert.ok(functionCallItems.some((item) => item.name === "malformed_tool_call"));
const completed = events.find((e) => e.event === "response.completed");
assert.ok(completed, "expected a normal response.completed terminal event, not an error");
});