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")]
|
#[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| {
|
||||||
|
|||||||
Reference in New Issue
Block a user