From e307ecf11d6ea5ac94ef6a570b5d6946d693b4b0 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Thu, 5 Mar 2026 17:29:25 +0000 Subject: [PATCH] fix(gemini): resolve 400 errors and unstable tool IDs in Gemini 3 models - Ensure is preserved in conversation history for Gemini 3 models. - Support multiple naming conventions (snake_case and camelCase). - Implement stable tool call ID tracking during streaming using a stateful map. - Improve extraction from both Gemini parts and function calls. - Fix incorrect tool call indices during streaming. --- src/providers/gemini.rs | 125 +++++++++++++++++++++++++--------------- 1 file changed, 78 insertions(+), 47 deletions(-) diff --git a/src/providers/gemini.rs b/src/providers/gemini.rs index e59d14b5..d16011c9 100644 --- a/src/providers/gemini.rs +++ b/src/providers/gemini.rs @@ -58,7 +58,9 @@ struct GeminiPart { #[serde(skip_serializing_if = "Option::is_none")] thought: Option, #[serde(skip_serializing_if = "Option::is_none", rename = "thought_signature")] - thought_signature: Option, + thought_signature_snake: Option, + #[serde(skip_serializing_if = "Option::is_none", rename = "thoughtSignature")] + thought_signature_camel: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -72,6 +74,8 @@ struct GeminiInlineData { struct GeminiFunctionCall { name: String, args: Value, + #[serde(skip_serializing_if = "Option::is_none", rename = "thought_signature")] + thought_signature: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -231,7 +235,8 @@ impl GeminiProvider { function_call: None, function_response: None, thought: None, - thought_signature: None, + thought_signature_snake: None, + thought_signature_camel: None, }); } } @@ -271,7 +276,8 @@ impl GeminiProvider { response: response_value, }), thought: None, - thought_signature: None, + thought_signature_snake: None, + thought_signature_camel: None, }); } else if msg.role == "assistant" { // Assistant messages: handle text, thought (reasoning), and tool_calls @@ -284,7 +290,8 @@ impl GeminiProvider { function_call: None, function_response: None, thought: None, - thought_signature: None, + thought_signature_snake: None, + thought_signature_camel: None, }); } } @@ -299,7 +306,8 @@ impl GeminiProvider { function_call: None, function_response: None, thought: Some(reasoning.clone()), - thought_signature: None, + thought_signature_snake: None, + thought_signature_camel: None, }); } } @@ -309,14 +317,10 @@ impl GeminiProvider { let args = serde_json::from_str::(&tc.function.arguments) .unwrap_or_else(|_| serde_json::json!({})); - // RESTORE: Use tc.id as thought_signature if it was originally one. - // We skip our own generated IDs (which start with 'call_') - // because they are not valid base64-encoded Gemini signatures. - let thought_signature = if !tc.id.starts_with("call_") { - Some(tc.id.clone()) - } else { - None - }; + // RESTORE: Use tc.id as thought_signature. + // Gemini 3 models require this field for any function call in the history. + // We include it regardless of format to ensure the model has context. + let thought_signature = Some(tc.id.clone()); parts.push(GeminiPart { text: None, @@ -324,10 +328,12 @@ impl GeminiProvider { function_call: Some(GeminiFunctionCall { name: tc.function.name.clone(), args, + thought_signature: thought_signature.clone(), }), function_response: None, thought: None, - thought_signature, + thought_signature_snake: thought_signature.clone(), + thought_signature_camel: thought_signature, }); } } @@ -343,7 +349,8 @@ impl GeminiProvider { function_call: None, function_response: None, thought: None, - thought_signature: None, + thought_signature_snake: None, + thought_signature_camel: None, }); } } @@ -362,7 +369,8 @@ impl GeminiProvider { function_call: None, function_response: None, thought: None, - thought_signature: None, + thought_signature_snake: None, + thought_signature_camel: None, }); } } @@ -398,7 +406,8 @@ impl GeminiProvider { function_call: None, function_response: None, thought: None, - thought_signature: None, + thought_signature_snake: None, + thought_signature_camel: None, }], }); } @@ -528,8 +537,11 @@ impl GeminiProvider { .filter(|p| p.function_call.is_some()) .map(|p| { let fc = p.function_call.as_ref().unwrap(); - // CAPTURE: Use thought_signature from Part as the ID if available - let id = p.thought_signature.clone().unwrap_or_else(|| format!("call_{}", Uuid::new_v4().simple())); + // CAPTURE: Try extracting thought_signature from multiple possible locations + let id = p.thought_signature_camel.clone() + .or_else(|| p.thought_signature_snake.clone()) + .or_else(|| fc.thought_signature.clone()) + .unwrap_or_else(|| format!("call_{}", Uuid::new_v4().simple())); ToolCall { id, @@ -545,32 +557,6 @@ impl GeminiProvider { if calls.is_empty() { None } else { Some(calls) } } - /// Extract tool call deltas from Gemini response parts for streaming. - fn extract_tool_call_deltas(parts: &[GeminiPart]) -> Option> { - let deltas: Vec = parts - .iter() - .enumerate() - .filter(|(_, p)| p.function_call.is_some()) - .map(|(i, p)| { - let fc = p.function_call.as_ref().unwrap(); - // CAPTURE: Use thought_signature from Part as the ID if available - let id = p.thought_signature.clone().unwrap_or_else(|| format!("call_{}", Uuid::new_v4().simple())); - - ToolCallDelta { - index: i as u32, - id: Some(id), - call_type: Some("function".to_string()), - function: Some(FunctionCallDelta { - name: Some(fc.name.clone()), - arguments: Some(serde_json::to_string(&fc.args).unwrap_or_else(|_| "{}".to_string())), - }), - } - }) - .collect(); - - if deltas.is_empty() { None } else { Some(deltas) } - } - /// Determine the appropriate base URL for the model. /// "preview" models often require the v1beta endpoint, but newer promoted ones may be on v1. fn get_base_url(&self, model: &str) -> String { @@ -852,6 +838,10 @@ impl super::Provider for GeminiProvider { let stream = async_stream::try_stream! { let mut es = es; + // Track tool call IDs by their part index to ensure stability during streaming. + // Gemini doesn't always include the thoughtSignature in every chunk for the same part. + let mut tool_call_ids: std::collections::HashMap = std::collections::HashMap::new(); + while let Some(event) = es.next().await { match event { Ok(Event::Message(msg)) => { @@ -862,7 +852,6 @@ impl super::Provider for GeminiProvider { gemini_response.candidates.len(), gemini_response.usage_metadata.is_some() ); - // (rest of processing remains identical) // Extract usage from usageMetadata if present (reported on every/last chunk) let stream_usage = gemini_response.usage_metadata.as_ref().map(|u| { @@ -890,7 +879,49 @@ impl super::Provider for GeminiProvider { .iter() .find_map(|p| p.thought.clone()); - let tool_calls = Self::extract_tool_call_deltas(&content_obj.parts); + // Extract tool calls with index and ID stability + let mut deltas = Vec::new(); + for (p_idx, p) in content_obj.parts.iter().enumerate() { + if let Some(fc) = &p.function_call { + let tool_call_idx = p_idx as u32; + + // Attempt to find a signature in any possible field + let signature = p.thought_signature_camel.clone() + .or_else(|| p.thought_signature_snake.clone()) + .or_else(|| fc.thought_signature.clone()); + + // Ensure the ID remains stable for this tool call index. + // If we found a real signature now, we update it; otherwise use the existing or new random ID. + let entry = tool_call_ids.entry(tool_call_idx); + let current_id = match entry { + std::collections::hash_map::Entry::Occupied(mut e) => { + if let Some(sig) = signature { + // If we previously had a 'call_' ID but now found a real signature, upgrade it. + if e.get().starts_with("call_") { + e.insert(sig); + } + } + e.get().clone() + } + std::collections::hash_map::Entry::Vacant(e) => { + let id = signature.unwrap_or_else(|| format!("call_{}", Uuid::new_v4().simple())); + e.insert(id.clone()); + id + } + }; + + deltas.push(ToolCallDelta { + index: tool_call_idx, + id: Some(current_id), + call_type: Some("function".to_string()), + function: Some(FunctionCallDelta { + name: Some(fc.name.clone()), + arguments: Some(serde_json::to_string(&fc.args).unwrap_or_else(|_| "{}".to_string())), + }), + }); + } + } + let tool_calls = if deltas.is_empty() { None } else { Some(deltas) }; // Determine finish_reason let finish_reason = candidate.finish_reason.as_ref().map(|fr| {