mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
* 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>
145 lines
6.0 KiB
JSON
145 lines
6.0 KiB
JSON
{
|
|
"name": "gemini-malformed-function-call-live-incident",
|
|
"description": "Real raw Gemini streaming response chunks captured from a live incident (dashboard log id 1784489701456-d8c0e9, 2026-07-19) where the model attempted to chain multiple tool calls into one malformed invocation and Gemini's own parser rejected it with finishReason MALFORMED_FUNCTION_CALL instead of a real functionCall part. Content has been sanitized: real file paths, personal automation project details, and job-search site URLs were replaced with generic placeholders. Structure (chunk count, field shapes, thought-then-empty-transition-then-malformed-finish sequence) is preserved exactly as captured, including the long opaque thoughtSignature blob (its exact bytes are irrelevant — only its presence/shape matters — so it has been shortened for fixture readability).",
|
|
"chunks": [
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"parts": [
|
|
{
|
|
"text": "**Initiating the Workflow**\n\nI've initiated the workflow, focusing on running `run_search.py`. The preliminary step is complete. Now, I'm fetching the initial target sites, specifically `site-a.example`, `site-b.example`, and `site-c.example`, as specified to begin the search.\n\n\n",
|
|
"thought": true
|
|
}
|
|
],
|
|
"role": "model"
|
|
},
|
|
"index": 0
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 24512,
|
|
"totalTokenCount": 24512,
|
|
"promptTokensDetails": [{ "modality": "TEXT", "tokenCount": 24512 }],
|
|
"serviceTier": "standard"
|
|
},
|
|
"modelVersion": "gemini-3.1-flash-lite",
|
|
"responseId": "resp-fixture-malformed-001"
|
|
},
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"parts": [
|
|
{
|
|
"text": "**Expanding the Search Parameters**\n\nI'm now expanding the search to include `site-d.example` and `site-e.example` in my `web_fetch` operation. My next steps are to filter the results against the state file and score them based on matching criteria. I will then post high matches to the results channel, update the state, and log the results.\n\n\n",
|
|
"thought": true
|
|
}
|
|
],
|
|
"role": "model"
|
|
},
|
|
"index": 0
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 24512,
|
|
"totalTokenCount": 24512,
|
|
"promptTokensDetails": [{ "modality": "TEXT", "tokenCount": 24512 }],
|
|
"serviceTier": "standard"
|
|
},
|
|
"modelVersion": "gemini-3.1-flash-lite",
|
|
"responseId": "resp-fixture-malformed-001"
|
|
},
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"parts": [
|
|
{
|
|
"text": "**Analyzing the Results**\n\nI'm now analyzing the output from the initial web fetch. I will check the data against processed_ids, filter for specific categories, and proceed with scoring based on these parameters. I'll then prepare to post high matches to the specified results channel, update the search state, and log the results.\n\n\n",
|
|
"thought": true
|
|
}
|
|
],
|
|
"role": "model"
|
|
},
|
|
"index": 0
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 24512,
|
|
"totalTokenCount": 24512,
|
|
"promptTokensDetails": [{ "modality": "TEXT", "tokenCount": 24512 }],
|
|
"serviceTier": "standard"
|
|
},
|
|
"modelVersion": "gemini-3.1-flash-lite",
|
|
"responseId": "resp-fixture-malformed-001"
|
|
},
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": { "parts": [{ "text": "" }], "role": "model" },
|
|
"index": 0
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 24512,
|
|
"candidatesTokenCount": 167,
|
|
"totalTokenCount": 25738,
|
|
"promptTokensDetails": [{ "modality": "TEXT", "tokenCount": 24512 }],
|
|
"thoughtsTokenCount": 1059,
|
|
"serviceTier": "standard"
|
|
},
|
|
"modelVersion": "gemini-3.1-flash-lite",
|
|
"responseId": "resp-fixture-malformed-001"
|
|
},
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"parts": [
|
|
{
|
|
"text": "",
|
|
"thoughtSignature": "FIXTURE_PLACEHOLDER_THOUGHT_SIGNATURE_OPAQUE_BASE64_BLOB_TRUNCATED_FOR_READABILITY_the_real_value_is_several_kb_of_base64_and_carries_no_meaning_for_this_test=="
|
|
}
|
|
],
|
|
"role": "model"
|
|
},
|
|
"index": 0
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 24512,
|
|
"candidatesTokenCount": 167,
|
|
"totalTokenCount": 25738,
|
|
"cachedContentTokenCount": 19504,
|
|
"promptTokensDetails": [{ "modality": "TEXT", "tokenCount": 24512 }],
|
|
"thoughtsTokenCount": 1059,
|
|
"serviceTier": "standard"
|
|
},
|
|
"modelVersion": "gemini-3.1-flash-lite",
|
|
"responseId": "resp-fixture-malformed-001"
|
|
},
|
|
{
|
|
"candidates": [
|
|
{
|
|
"content": { "parts": [{ "text": "" }], "role": "model" },
|
|
"finishReason": "MALFORMED_FUNCTION_CALL",
|
|
"index": 0,
|
|
"finishMessage": "Malformed function call: call:default_api:exec{command: cd /home/user/workspace && venv/bin/python run_search.py , web_fetch \"https://site-a.example/jobs\", web_fetch \"https://site-b.example/careers\", web_fetch \"https://site-c.example/jobs\", web_fetch \"https://site-d.example/jobs\", web_fetch \"https://site-e.example/careers\" ,reason: Executing the search workflow: running the scraper and fetching the target sites. }"
|
|
}
|
|
],
|
|
"usageMetadata": {
|
|
"promptTokenCount": 24512,
|
|
"candidatesTokenCount": 167,
|
|
"totalTokenCount": 25738,
|
|
"cachedContentTokenCount": 19504,
|
|
"promptTokensDetails": [{ "modality": "TEXT", "tokenCount": 24512 }],
|
|
"thoughtsTokenCount": 1059,
|
|
"serviceTier": "standard"
|
|
},
|
|
"modelVersion": "gemini-3.1-flash-lite",
|
|
"responseId": "resp-fixture-malformed-001"
|
|
}
|
|
]
|
|
}
|