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.
This commit is contained in:
@@ -58,7 +58,9 @@ struct GeminiPart {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thought: Option<String>,
|
||||
#[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)]
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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::<Value>(&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<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.
|
||||
/// "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<u32, String> = 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| {
|
||||
|
||||
Reference in New Issue
Block a user