Files
OmniRoute/tests/unit/gemini-tool-choice.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

117 lines
4.4 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
// tool_choice was never read anywhere in the OpenAI -> Gemini request translator —
// result.toolConfig was unconditionally hardcoded to { mode: "VALIDATED" } whenever
// tools were present, regardless of what the caller sent. VALIDATED allows the model
// to 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. Investigating a live report that gemini-3.1-flash-lite frequently
// narrates an intended tool call in plain text instead of emitting one (dashboard log
// id 1784591483850-49c408, zero functionCall parts, finishReason STOP, nothing but
// prose) surfaced that tool_choice: "required" had no way to reach Gemini at all —
// this is the fix, and the mechanism the live test compares against a baseline.
const { openaiToGeminiRequest, openaiToCloudCodeGeminiRequest } =
await import("../../open-sse/translator/request/openai-to-gemini.ts");
type ToolConfigResult = {
toolConfig?: { functionCallingConfig: { mode: string; allowedFunctionNames?: string[] } };
};
const SAMPLE_TOOLS = [
{
type: "function",
function: { name: "run_command", description: "Run a shell command", parameters: {} },
},
];
function baseBody(toolChoice?: unknown) {
return {
messages: [{ role: "user", content: "hi" }],
tools: SAMPLE_TOOLS,
...(toolChoice !== undefined ? { tool_choice: toolChoice } : {}),
};
}
test("no tool_choice (unset) keeps the existing VALIDATED default — no behavior change", () => {
const result = openaiToGeminiRequest("gemini-2.5-pro", baseBody(), false) as ToolConfigResult;
assert.equal(result.toolConfig?.functionCallingConfig.mode, "VALIDATED");
});
test('tool_choice: "auto" maps to VALIDATED (same as unset)', () => {
const result = openaiToGeminiRequest(
"gemini-2.5-pro",
baseBody("auto"),
false
) as ToolConfigResult;
assert.equal(result.toolConfig?.functionCallingConfig.mode, "VALIDATED");
});
test('tool_choice: "required" maps to Gemini ANY mode (forces a function call)', () => {
const result = openaiToGeminiRequest(
"gemini-2.5-pro",
baseBody("required"),
false
) as ToolConfigResult;
assert.equal(result.toolConfig?.functionCallingConfig.mode, "ANY");
});
test('tool_choice: "any" (OpenAI-compatible alias) also maps to ANY', () => {
const result = openaiToGeminiRequest(
"gemini-2.5-pro",
baseBody("any"),
false
) as ToolConfigResult;
assert.equal(result.toolConfig?.functionCallingConfig.mode, "ANY");
});
test('tool_choice: "none" maps to Gemini NONE mode (disables function calling)', () => {
const result = openaiToGeminiRequest(
"gemini-2.5-pro",
baseBody("none"),
false
) as ToolConfigResult;
assert.equal(result.toolConfig?.functionCallingConfig.mode, "NONE");
});
test("tool_choice forcing a specific function maps to ANY with allowedFunctionNames", () => {
const result = openaiToGeminiRequest(
"gemini-2.5-pro",
baseBody({ type: "function", function: { name: "run_command" } }),
false
) as ToolConfigResult;
assert.equal(result.toolConfig?.functionCallingConfig.mode, "ANY");
assert.deepEqual(result.toolConfig?.functionCallingConfig.allowedFunctionNames, ["run_command"]);
});
test("no tools present: toolConfig is not set regardless of tool_choice", () => {
const result = openaiToGeminiRequest(
"gemini-2.5-pro",
{ messages: [{ role: "user", content: "hi" }], tool_choice: "required" },
false
) as ToolConfigResult;
assert.equal(result.toolConfig, undefined);
});
// Antigravity / Cloud Code envelope path (wrapInCloudCodeEnvelope) previously
// re-derived its own hardcoded VALIDATED independently of the base translator —
// it now reuses whatever openaiToGeminiBase already computed from tool_choice.
test("Antigravity/Cloud Code path also honors tool_choice: required", () => {
const result = openaiToCloudCodeGeminiRequest(
"gemini-2.5-pro",
baseBody("required"),
false
) as ToolConfigResult;
assert.equal(result.toolConfig?.functionCallingConfig.mode, "ANY");
});
test("Antigravity/Cloud Code path defaults to VALIDATED when tool_choice is unset (no regression)", () => {
const result = openaiToCloudCodeGeminiRequest(
"gemini-2.5-pro",
baseBody(),
false
) as ToolConfigResult;
assert.equal(result.toolConfig?.functionCallingConfig.mode, "VALIDATED");
});