fix(gemini): resolve 400 errors and unstable tool IDs in Gemini 3 models
Some checks failed
CI / Check (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Formatting (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Release Build (push) Has been cancelled

- 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.
This commit is contained in:
2026-03-05 17:29:25 +00:00
parent eac3781079
commit e307ecf11d

View File

@@ -58,7 +58,9 @@ struct GeminiPart {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
thought: Option<String>, thought: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "thought_signature")] #[serde(skip_serializing_if = "Option::is_none", rename = "thought_signature")]
thought_signature: Option<String>, thought_signature_snake: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "thoughtSignature")]
thought_signature_camel: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -72,6 +74,8 @@ struct GeminiInlineData {
struct GeminiFunctionCall { struct GeminiFunctionCall {
name: String, name: String,
args: Value, args: Value,
#[serde(skip_serializing_if = "Option::is_none", rename = "thought_signature")]
thought_signature: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -231,7 +235,8 @@ impl GeminiProvider {
function_call: None, function_call: None,
function_response: None, function_response: None,
thought: None, thought: None,
thought_signature: None, thought_signature_snake: None,
thought_signature_camel: None,
}); });
} }
} }
@@ -271,7 +276,8 @@ impl GeminiProvider {
response: response_value, response: response_value,
}), }),
thought: None, thought: None,
thought_signature: None, thought_signature_snake: None,
thought_signature_camel: None,
}); });
} else if msg.role == "assistant" { } else if msg.role == "assistant" {
// Assistant messages: handle text, thought (reasoning), and tool_calls // Assistant messages: handle text, thought (reasoning), and tool_calls
@@ -284,7 +290,8 @@ impl GeminiProvider {
function_call: None, function_call: None,
function_response: None, function_response: None,
thought: None, thought: None,
thought_signature: None, thought_signature_snake: None,
thought_signature_camel: None,
}); });
} }
} }
@@ -299,7 +306,8 @@ impl GeminiProvider {
function_call: None, function_call: None,
function_response: None, function_response: None,
thought: Some(reasoning.clone()), 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::<Value>(&tc.function.arguments) let args = serde_json::from_str::<Value>(&tc.function.arguments)
.unwrap_or_else(|_| serde_json::json!({})); .unwrap_or_else(|_| serde_json::json!({}));
// RESTORE: Use tc.id as thought_signature if it was originally one. // RESTORE: Use tc.id as thought_signature.
// We skip our own generated IDs (which start with 'call_') // Gemini 3 models require this field for any function call in the history.
// because they are not valid base64-encoded Gemini signatures. // We include it regardless of format to ensure the model has context.
let thought_signature = if !tc.id.starts_with("call_") { let thought_signature = Some(tc.id.clone());
Some(tc.id.clone())
} else {
None
};
parts.push(GeminiPart { parts.push(GeminiPart {
text: None, text: None,
@@ -324,10 +328,12 @@ impl GeminiProvider {
function_call: Some(GeminiFunctionCall { function_call: Some(GeminiFunctionCall {
name: tc.function.name.clone(), name: tc.function.name.clone(),
args, args,
thought_signature: thought_signature.clone(),
}), }),
function_response: None, function_response: None,
thought: 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_call: None,
function_response: None, function_response: None,
thought: None, thought: None,
thought_signature: None, thought_signature_snake: None,
thought_signature_camel: None,
}); });
} }
} }
@@ -362,7 +369,8 @@ impl GeminiProvider {
function_call: None, function_call: None,
function_response: None, function_response: None,
thought: None, thought: None,
thought_signature: None, thought_signature_snake: None,
thought_signature_camel: None,
}); });
} }
} }
@@ -398,7 +406,8 @@ impl GeminiProvider {
function_call: None, function_call: None,
function_response: None, function_response: None,
thought: 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()) .filter(|p| p.function_call.is_some())
.map(|p| { .map(|p| {
let fc = p.function_call.as_ref().unwrap(); let fc = p.function_call.as_ref().unwrap();
// CAPTURE: Use thought_signature from Part as the ID if available // CAPTURE: Try extracting thought_signature from multiple possible locations
let id = p.thought_signature.clone().unwrap_or_else(|| format!("call_{}", Uuid::new_v4().simple())); 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 { ToolCall {
id, id,
@@ -545,32 +557,6 @@ impl GeminiProvider {
if calls.is_empty() { None } else { Some(calls) } 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<Vec<ToolCallDelta>> {
let deltas: Vec<ToolCallDelta> = 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. /// Determine the appropriate base URL for the model.
/// "preview" models often require the v1beta endpoint, but newer promoted ones may be on v1. /// "preview" models often require the v1beta endpoint, but newer promoted ones may be on v1.
fn get_base_url(&self, model: &str) -> String { fn get_base_url(&self, model: &str) -> String {
@@ -852,6 +838,10 @@ impl super::Provider for GeminiProvider {
let stream = async_stream::try_stream! { let stream = async_stream::try_stream! {
let mut es = es; 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<u32, String> = std::collections::HashMap::new();
while let Some(event) = es.next().await { while let Some(event) = es.next().await {
match event { match event {
Ok(Event::Message(msg)) => { Ok(Event::Message(msg)) => {
@@ -862,7 +852,6 @@ impl super::Provider for GeminiProvider {
gemini_response.candidates.len(), gemini_response.candidates.len(),
gemini_response.usage_metadata.is_some() gemini_response.usage_metadata.is_some()
); );
// (rest of processing remains identical)
// Extract usage from usageMetadata if present (reported on every/last chunk) // Extract usage from usageMetadata if present (reported on every/last chunk)
let stream_usage = gemini_response.usage_metadata.as_ref().map(|u| { let stream_usage = gemini_response.usage_metadata.as_ref().map(|u| {
@@ -890,7 +879,49 @@ impl super::Provider for GeminiProvider {
.iter() .iter()
.find_map(|p| p.thought.clone()); .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 // Determine finish_reason
let finish_reason = candidate.finish_reason.as_ref().map(|fr| { let finish_reason = candidate.finish_reason.as_ref().map(|fr| {