fix(gemini): ensure final finish_reason is 'tool_calls' if any tools were seen
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

Gemini often sends tool calls in one chunk and then 'STOP' in a final chunk.
If we pass the raw 'stop' at the end, clients stop and ignore the previously
received tool calls. We now track if any tools were seen and override the
final 'stop' to 'tool_calls'.
This commit is contained in:
2026-03-05 17:50:25 +00:00
parent 5c5f836eca
commit 6440e8cc13

View File

@@ -844,6 +844,7 @@ impl super::Provider for GeminiProvider {
// Track tool call IDs by their part index to ensure stability during streaming. // 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. // 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(); let mut tool_call_ids: std::collections::HashMap<u32, String> = std::collections::HashMap::new();
let mut seen_tool_calls = false;
while let Some(event) = es.next().await { while let Some(event) = es.next().await {
match event { match event {
@@ -887,6 +888,7 @@ impl super::Provider for GeminiProvider {
let mut deltas = Vec::new(); let mut deltas = Vec::new();
for (p_idx, p) in content_obj.parts.iter().enumerate() { for (p_idx, p) in content_obj.parts.iter().enumerate() {
if let Some(fc) = &p.function_call { if let Some(fc) = &p.function_call {
seen_tool_calls = true;
let tool_call_idx = p_idx as u32; let tool_call_idx = p_idx as u32;
// Attempt to find a signature in sibling fields // Attempt to find a signature in sibling fields
@@ -927,18 +929,22 @@ impl super::Provider for GeminiProvider {
let tool_calls = if deltas.is_empty() { None } else { Some(deltas) }; let tool_calls = if deltas.is_empty() { None } else { Some(deltas) };
// Determine finish_reason // Determine finish_reason
// STRATEGY: If we have tool calls, the finish_reason MUST be "tool_calls" // STRATEGY: If we have tool calls in this chunk, OR if we have seen them
// to comply with OpenAI-style expectations and ensure the client continues. // previously in the stream, the finish_reason MUST be "tool_calls"
let finish_reason = if tool_calls.is_some() { // if the provider signals a stop. This ensures the client executes tools.
Some("tool_calls".to_string()) let mut finish_reason = candidate.finish_reason.as_ref().map(|fr| {
} else { match fr.as_str() {
candidate.finish_reason.as_ref().map(|fr| { "STOP" => "stop".to_string(),
match fr.as_str() { _ => fr.to_lowercase(),
"STOP" => "stop".to_string(), }
_ => fr.to_lowercase(), });
}
}) if seen_tool_calls && finish_reason.as_deref() == Some("stop") {
}; finish_reason = Some("tool_calls".to_string());
} else if tool_calls.is_some() && finish_reason.is_none() {
// Optional: Could signal tool_calls here too, but OpenAI often waits until EOF.
// For now we only override it at the actual stop signal.
}
// Avoid emitting completely empty chunks unless they carry usage. // Avoid emitting completely empty chunks unless they carry usage.
if !content.is_empty() || reasoning_content.is_some() || tool_calls.is_some() || stream_usage.is_some() { if !content.is_empty() || reasoning_content.is_some() || tool_calls.is_some() || stream_usage.is_some() {