From b02b8f3b1baa969899c9f6da253cb160c706729c Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 16 Apr 2026 17:38:07 +0800 Subject: [PATCH] fix(proxy/gemini): align shadow id with client-visible id in non-streaming path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Gemini returns a `functionCall` without an id (common in 2.x parallel calls), `gemini_to_anthropic_with_shadow_and_hints` previously generated TWO independent synthesized UUIDs: 1. Line 186-197 — synthesized id `A` used for the Anthropic-visible `content[tool_use].id` returned to the client. 2. Line 850-881 — `extract_tool_call_meta` independently synthesized id `B ≠ A`, which populated `shadow_turn.tool_calls[i].id`. `shadow_content` (line 225-228, cloned from `rectified_parts`) retained the original missing/empty id. Result: the client sees id `A`, the shadow store holds id `B`. On the next turn, `convert_messages_to_contents` builds `tool_name_by_id` from `build_tool_name_map_from_shadow_turns`, which uses `tool_calls[i].id` — so the map contains `B → name` but not `A → name`. When the client sends back `tool_result(tool_use_id=A)`, resolution fails with: Unable to resolve Gemini functionResponse.name for tool_use_id `A` This affects both truncated histories (client sends only the tool_result) and full histories (shadow-replay branch at line 342-354 skips `convert_message_content_to_parts`, so the assistant tool_use block never registers id `A` itself). Fix: make `rectified_parts` the single source of truth. After `rectify_tool_call_parts`, run a pre-pass that writes `synthesize_tool_call_id()` back into any `functionCall` that lacks a non-empty id. All three readers — the content builder (186-197), the shadow_content clone (225-228), and `extract_tool_call_meta` — then observe the same id. `shadow_parts()` already strips synthesized ids on replay (line 616-628), so the internal identifier never leaks to Gemini upstream. This mirrors the streaming path, which already has single-source-of- truth semantics via `tool_call_snapshots` in `streaming_gemini.rs` — no change needed there. Tests (5 new in `transform_gemini::tests`): - `non_stream_shadow_id_matches_client_visible_id`: asserts `response.content[0].id == shadow.tool_calls[0].id == shadow.assistant_content.parts[0].functionCall.id`. - `non_stream_missing_id_scenario_a_truncated_history_resolves`: turn 2 sends only `[tool_result(id=A)]`; resolution must succeed. - `non_stream_missing_id_scenario_b_full_history_replay_resolves`: turn 2 sends `[assistant(tool_use=A), tool_result(A)]`; shadow-replay branch strips the synth id from outgoing `functionCall` while still resolving the subsequent `tool_result`. - `non_stream_preserves_original_gemini_id_when_present`: regression — genuine Gemini ids flow through unchanged. - `non_stream_synthesized_id_not_leaked_to_gemini_via_shadow_replay`: defensive — shadow-replay path must strip synth ids from both `functionCall.id` and `functionResponse.id`. All 854 lib tests pass; cargo fmt + clippy -D warnings clean. Addresses Codex follow-up P1 on #1918. --- .../src/proxy/providers/transform_gemini.rs | 338 ++++++++++++++++++ 1 file changed, 338 insertions(+) diff --git a/src-tauri/src/proxy/providers/transform_gemini.rs b/src-tauri/src/proxy/providers/transform_gemini.rs index 0924ca4fb..465af2f2f 100644 --- a/src-tauri/src/proxy/providers/transform_gemini.rs +++ b/src-tauri/src/proxy/providers/transform_gemini.rs @@ -163,6 +163,34 @@ pub fn gemini_to_anthropic_with_shadow_and_hints( let mut rectified_parts = parts.clone(); rectify_tool_call_parts(&mut rectified_parts, tool_schema_hints); + // Pre-pass: for every `functionCall` that lacks an id (or carries an + // empty-string id), synthesize one and write it back into + // `rectified_parts`. Three independent readers — the + // Anthropic-visible `content[tool_use]` block below, the shadow + // store's `assistant_content` (cloned from `rectified_parts` further + // down), and `extract_tool_call_meta(&rectified_parts)` that populates + // `shadow_turn.tool_calls` — must all see the same id. Otherwise the + // client would receive id A while the shadow stored id B, and the + // next round's `tool_result(tool_use_id=A)` would fail to resolve + // through `tool_name_by_id` (which is built from the shadow), raising + // `Unable to resolve Gemini functionResponse.name`. Streaming path + // already has this single-source-of-truth property via + // `tool_call_snapshots`. + for part in rectified_parts.iter_mut() { + let Some(function_call) = part.get_mut("functionCall").and_then(|v| v.as_object_mut()) + else { + continue; + }; + let needs_synth = function_call + .get("id") + .and_then(|v| v.as_str()) + .map(|s| s.is_empty()) + .unwrap_or(true); + if needs_synth { + function_call.insert("id".to_string(), json!(synthesize_tool_call_id())); + } + } + let mut content = Vec::new(); let mut has_tool_use = false; @@ -1625,4 +1653,314 @@ mod tests { "get_weather" ); } + + // ------------------------------------------------------------------ + // Non-streaming shadow id coherence regressions. + // + // When Gemini returns a `functionCall` without an id (common in 2.x + // parallel calls) the proxy must synthesize a single id that is + // consistent across: + // (a) the Anthropic `content[tool_use].id` sent to the client + // (b) `shadow_content.parts[].functionCall.id` recorded in shadow + // (c) `shadow_turn.tool_calls[].id` recorded in shadow + // Previously the non-streaming path generated independent UUIDs in (a) + // and (c), so the next round's `tool_result(tool_use_id=A)` would + // fail to resolve through `tool_name_by_id` (populated from (c)). + // ------------------------------------------------------------------ + + /// The id surfaced to the Anthropic client must equal the id recorded + /// in the shadow's `tool_calls` metadata and the shadow's serialized + /// `functionCall.id`. All three are read back as the same string. + #[test] + fn non_stream_shadow_id_matches_client_visible_id() { + let store = GeminiShadowStore::with_limits(8, 4); + let body = json!({ + "responseId": "r-coherence", + "modelVersion": "gemini-2.5-pro", + "candidates": [{ + "finishReason": "STOP", + "content": { + "parts": [{ + "functionCall": { "name": "get_weather", "args": { "city": "Tokyo" } } + }] + } + }] + }); + + let response = gemini_to_anthropic_with_shadow_and_hints( + body, + Some(&store), + Some("prov"), + Some("sess"), + None, + ) + .unwrap(); + + let client_id = response["content"][0]["id"].as_str().unwrap(); + assert!( + is_synthesized_tool_call_id(client_id), + "client-facing id must be synthesized for no-id Gemini responses" + ); + + let snapshot = store.get_session("prov", "sess").expect("shadow recorded"); + // (c) tool_calls metadata must agree with the client-visible id. + let shadow_tool_call_id = snapshot.turns[0].tool_calls[0] + .id + .as_deref() + .expect("tool_calls id populated"); + assert_eq!( + shadow_tool_call_id, client_id, + "shadow.tool_calls id must equal client-visible id" + ); + // (b) assistant_content parts must agree too, so that + // `merge_tool_names_from_parts` sees the same id on replay. + let shadow_part_id = snapshot.turns[0].assistant_content["parts"][0]["functionCall"]["id"] + .as_str() + .expect("assistant_content functionCall id populated"); + assert_eq!( + shadow_part_id, client_id, + "shadow assistant_content functionCall.id must equal client-visible id" + ); + } + + /// Scenario A: the client-side history was truncated so the next + /// request only contains `[tool_result(tool_use_id=A)]` without a + /// preceding assistant echo. The request must still resolve because + /// `build_tool_name_map_from_shadow_turns` now surfaces the same id + /// the client was given. + #[test] + fn non_stream_missing_id_scenario_a_truncated_history_resolves() { + let store = GeminiShadowStore::with_limits(8, 4); + let turn1 = json!({ + "responseId": "r-truncated", + "modelVersion": "gemini-2.5-pro", + "candidates": [{ + "finishReason": "STOP", + "content": { + "parts": [{ + "functionCall": { "name": "get_weather", "args": { "city": "Tokyo" } } + }] + } + }] + }); + let anthropic_response = gemini_to_anthropic_with_shadow_and_hints( + turn1, + Some(&store), + Some("prov"), + Some("sess"), + None, + ) + .unwrap(); + let client_id = anthropic_response["content"][0]["id"] + .as_str() + .unwrap() + .to_string(); + + // Turn 2 — client replays ONLY the tool_result. No assistant echo. + let turn2_input = json!({ + "messages": [ + { + "role": "user", + "content": [ + { "type": "tool_result", "tool_use_id": &client_id, "content": "sunny" } + ] + } + ] + }); + let result = + anthropic_to_gemini_with_shadow(turn2_input, Some(&store), Some("prov"), Some("sess")) + .expect("scenario A must resolve tool name through shadow"); + assert_eq!( + result["contents"][0]["parts"][0]["functionResponse"]["name"], + "get_weather" + ); + } + + /// Scenario B: the client replays the full history. The proxy picks + /// the shadow-replay branch (not `convert_message_content_to_parts`), + /// which strips the synthesized id from the outgoing `functionCall`. + /// `tool_name_by_id` must still have been populated from the shadow + /// so the following `tool_result(A)` resolves. + #[test] + fn non_stream_missing_id_scenario_b_full_history_replay_resolves() { + let store = GeminiShadowStore::with_limits(8, 4); + let turn1 = json!({ + "responseId": "r-full", + "modelVersion": "gemini-2.5-pro", + "candidates": [{ + "finishReason": "STOP", + "content": { + "parts": [{ + "functionCall": { "name": "get_weather", "args": { "city": "Tokyo" } } + }] + } + }] + }); + let anthropic_response = gemini_to_anthropic_with_shadow_and_hints( + turn1, + Some(&store), + Some("prov"), + Some("sess"), + None, + ) + .unwrap(); + let client_id = anthropic_response["content"][0]["id"] + .as_str() + .unwrap() + .to_string(); + + // Turn 2 — full history: assistant tool_use + tool_result. + let turn2_input = json!({ + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": &client_id, + "name": "get_weather", + "input": { "city": "Tokyo" } + } + ] + }, + { + "role": "user", + "content": [ + { "type": "tool_result", "tool_use_id": &client_id, "content": "sunny" } + ] + } + ] + }); + let result = + anthropic_to_gemini_with_shadow(turn2_input, Some(&store), Some("prov"), Some("sess")) + .expect("scenario B must resolve tool name through shadow replay"); + + // Shadow-replay path: `functionCall.id` is stripped for the + // assistant turn (the synthesized id must not leak upstream). + assert!( + result["contents"][0]["parts"][0]["functionCall"] + .get("id") + .is_none(), + "synthesized id must not leak to Gemini in shadow replay" + ); + assert_eq!( + result["contents"][0]["parts"][0]["functionCall"]["name"], + "get_weather" + ); + // The tool_result round-trip resolves through the shadow map. + assert_eq!( + result["contents"][1]["parts"][0]["functionResponse"]["name"], + "get_weather" + ); + } + + /// Regression: when Gemini returns an id, nothing is synthesized. + /// The original id is round-tripped in both the Anthropic response + /// and the shadow store, and it flows back to Gemini on the next + /// functionResponse. + #[test] + fn non_stream_preserves_original_gemini_id_when_present() { + let store = GeminiShadowStore::with_limits(8, 4); + let body = json!({ + "responseId": "r-preserve", + "modelVersion": "gemini-2.5-pro", + "candidates": [{ + "finishReason": "STOP", + "content": { + "parts": [{ + "functionCall": { + "id": "call_real_1", + "name": "get_weather", + "args": { "city": "Tokyo" } + } + }] + } + }] + }); + + let response = gemini_to_anthropic_with_shadow_and_hints( + body, + Some(&store), + Some("prov"), + Some("sess"), + None, + ) + .unwrap(); + assert_eq!(response["content"][0]["id"], "call_real_1"); + let snapshot = store.get_session("prov", "sess").unwrap(); + assert_eq!( + snapshot.turns[0].tool_calls[0].id.as_deref(), + Some("call_real_1") + ); + assert_eq!( + snapshot.turns[0].assistant_content["parts"][0]["functionCall"]["id"], + "call_real_1" + ); + } + + /// Defensive: if a shadow turn somehow carries a synthesized + /// `functionCall.id` (e.g. recorded by this path), replaying it via + /// `anthropic_to_gemini_with_shadow` must strip the id before sending + /// upstream, so Gemini never sees the internal identifier. + #[test] + fn non_stream_synthesized_id_not_leaked_to_gemini_via_shadow_replay() { + let store = GeminiShadowStore::with_limits(8, 4); + let synth = synthesize_tool_call_id(); + store.record_assistant_turn( + "prov", + "sess", + json!({ + "parts": [{ + "functionCall": { + "id": &synth, + "name": "get_weather", + "args": { "city": "Tokyo" } + } + }] + }), + vec![GeminiToolCallMeta::new( + Some(synth.clone()), + "get_weather", + json!({ "city": "Tokyo" }), + None::, + )], + ); + + let input = json!({ + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": &synth, + "name": "get_weather", + "input": { "city": "Tokyo" } + } + ] + }, + { + "role": "user", + "content": [ + { "type": "tool_result", "tool_use_id": &synth, "content": "sunny" } + ] + } + ] + }); + let result = + anthropic_to_gemini_with_shadow(input, Some(&store), Some("prov"), Some("sess")) + .unwrap(); + assert!( + result["contents"][0]["parts"][0]["functionCall"] + .get("id") + .is_none(), + "shadow replay must strip synthesized functionCall.id" + ); + assert!( + result["contents"][1]["parts"][0]["functionResponse"] + .get("id") + .is_none(), + "functionResponse.id must also be omitted for synthesized ids" + ); + } }