fix(gemini-native): resolve functionResponse.name and replay thought_signature for synthesized tool call IDs (#2814)

* fix(gemini-native): resolve functionResponse.name and thought_signature replay for synthesized tool call IDs

Two related bugs in the Gemini Native format conversion layer:

1. **functionResponse.name resolution** (422 error): When Gemini's parallel
   function calls omit the id field, cc-switch synthesizes gemini_synth_*
   IDs. These are stored in the shadow store but can be lost in long sessions,
   causing subsequent tool_result blocks to fail. Fix: pre-scan all assistant
   messages in the request body to seed the tool_name_by_id map, and add a
   last-resort fallback that scans the current content array for matching
   tool_use blocks.

2. **thought_signature replay** (400 error): The Anthropic Messages format
   strips thoughtSignature from tool_use blocks, but Gemini requires it on
   every functionCall in multi-turn tool-use exchanges. Fix: build a
   thought_signature_by_id map from shadow turns and attach thoughtSignature
   when converting tool_use back to functionCall.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* style: run cargo fmt on transform_gemini.rs

---------

Co-authored-by: Tiancrimson <tiancrimson@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
Tiancrimson
2026-05-18 11:25:35 +09:00
committed by GitHub
parent bbce75fcda
commit 5c79cf64a4
@@ -342,7 +342,39 @@ fn convert_messages_to_contents(
} else {
shadow_turns
};
// Build tool name and thought_signature maps from shadow store.
// These are used to resolve tool_result→functionResponse names and to
// attach thought signatures when replaying tool_use→functionCall.
let mut tool_name_by_id = build_tool_name_map_from_shadow_turns(shadow_turns);
let mut thought_signature_by_id = build_thought_signature_map_from_shadow_turns(shadow_turns);
// Pre-scan all assistant messages in the request body to seed
// tool_name_by_id with every tool_use id mentioned in the conversation
// history. This ensures tool_result blocks can always resolve their
// function name even when the shadow store has aged out the relevant
// turn (e.g. long conversations, session restarts, or concurrent
// session churn).
for message in messages {
if message.get("role").and_then(|v| v.as_str()) != Some("assistant") {
continue;
}
if let Some(blocks) = message.get("content").and_then(|c| c.as_array()) {
for block in blocks {
if block.get("type").and_then(|v| v.as_str()) != Some("tool_use") {
continue;
}
let id = block.get("id").and_then(|v| v.as_str()).unwrap_or("");
let name = block.get("name").and_then(|v| v.as_str()).unwrap_or("");
if !id.is_empty() && !name.is_empty() {
tool_name_by_id
.entry(id.to_string())
.or_insert_with(|| name.to_string());
}
}
}
}
let shadow_start_index = total_assistant_messages.saturating_sub(effective_shadow_turns.len());
let mut assistant_seen_index = 0usize;
@@ -371,6 +403,7 @@ fn convert_messages_to_contents(
used_shadow_indices.insert(index);
let shadow_turn = &effective_shadow_turns[index];
merge_tool_names_from_shadow(shadow_turn, &mut tool_name_by_id);
merge_thought_signatures_from_shadow(shadow_turn, &mut thought_signature_by_id);
if let Some(parts) = shadow_parts(&shadow_turn.assistant_content) {
parts
} else {
@@ -378,6 +411,7 @@ fn convert_messages_to_contents(
message.get("content"),
role,
&mut tool_name_by_id,
&thought_signature_by_id,
)?
}
} else {
@@ -385,10 +419,16 @@ fn convert_messages_to_contents(
message.get("content"),
role,
&mut tool_name_by_id,
&thought_signature_by_id,
)?
}
} else {
convert_message_content_to_parts(message.get("content"), role, &mut tool_name_by_id)?
convert_message_content_to_parts(
message.get("content"),
role,
&mut tool_name_by_id,
&thought_signature_by_id,
)?
};
if role == "assistant" {
@@ -485,6 +525,7 @@ fn convert_message_content_to_parts(
content: Option<&Value>,
role: &str,
tool_name_by_id: &mut std::collections::HashMap<String, String>,
thought_signature_by_id: &std::collections::HashMap<String, String>,
) -> Result<Vec<Value>, ProxyError> {
let Some(content) = content else {
return Ok(Vec::new());
@@ -590,6 +631,16 @@ fn convert_message_content_to_parts(
function_call["id"] = json!(id);
}
// Re-attach the thought_signature that Gemini originally
// associated with this functionCall. The Anthropic format
// strips it from the tool_use block, but Gemini requires it
// on every functionCall in a multi-turn tool-use exchange.
// Without replaying the stored signature the upstream may
// reject with "missing a `thought_signature`".
if let Some(sig) = thought_signature_by_id.get(id) {
function_call["thoughtSignature"] = json!(sig);
}
parts.push(json!({ "functionCall": function_call }));
}
"tool_result" => {
@@ -600,6 +651,20 @@ fn convert_message_content_to_parts(
let name = tool_name_by_id
.get(tool_use_id)
.cloned()
.or_else(|| {
// Last-resort fallback: scan every block in this content
// array for a tool_use whose id matches. This catches
// edge cases where the tool_use lives in a different
// content block of the same message (non-standard client
// behaviour) or in a re-ordered message array.
blocks.iter().find_map(|b| {
let t = b.get("type").and_then(|v| v.as_str())?;
if t != "tool_use" { return None; }
let id = b.get("id").and_then(|v| v.as_str())?;
if id != tool_use_id { return None; }
b.get("name").and_then(|v| v.as_str()).map(|n| n.to_string())
})
})
.ok_or_else(|| {
ProxyError::TransformError(format!(
"Unable to resolve Gemini functionResponse.name for tool_use_id `{tool_use_id}`"
@@ -874,6 +939,27 @@ fn build_tool_name_map_from_shadow_turns(
tool_name_by_id
}
fn build_thought_signature_map_from_shadow_turns(
shadow_turns: &[GeminiAssistantTurn],
) -> HashMap<String, String> {
let mut thought_signature_by_id = HashMap::new();
for turn in shadow_turns {
merge_thought_signatures_from_shadow(turn, &mut thought_signature_by_id);
}
thought_signature_by_id
}
fn merge_thought_signatures_from_shadow(
turn: &GeminiAssistantTurn,
thought_signature_by_id: &mut HashMap<String, String>,
) {
for tool_call in &turn.tool_calls {
if let (Some(id), Some(sig)) = (&tool_call.id, &tool_call.thought_signature) {
thought_signature_by_id.insert(id.clone(), sig.clone());
}
}
}
fn merge_tool_names_from_parts(parts: &[Value], tool_name_by_id: &mut HashMap<String, String>) {
for part in parts {
let Some(function_call) = part.get("functionCall") else {