From 6440e8cc13f9fd78492a861d298d806bb7750575 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Thu, 5 Mar 2026 17:50:25 +0000 Subject: [PATCH] fix(gemini): ensure final finish_reason is 'tool_calls' if any tools were seen 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'. --- src/providers/gemini.rs | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/providers/gemini.rs b/src/providers/gemini.rs index be094c96..9720cdcb 100644 --- a/src/providers/gemini.rs +++ b/src/providers/gemini.rs @@ -844,6 +844,7 @@ impl super::Provider for GeminiProvider { // 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(); + let mut seen_tool_calls = false; while let Some(event) = es.next().await { match event { @@ -887,6 +888,7 @@ impl super::Provider for GeminiProvider { let mut deltas = Vec::new(); for (p_idx, p) in content_obj.parts.iter().enumerate() { if let Some(fc) = &p.function_call { + seen_tool_calls = true; let tool_call_idx = p_idx as u32; // 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) }; // Determine finish_reason - // STRATEGY: If we have tool calls, the finish_reason MUST be "tool_calls" - // to comply with OpenAI-style expectations and ensure the client continues. - let finish_reason = if tool_calls.is_some() { - Some("tool_calls".to_string()) - } else { - candidate.finish_reason.as_ref().map(|fr| { - match fr.as_str() { - "STOP" => "stop".to_string(), - _ => fr.to_lowercase(), - } - }) - }; + // STRATEGY: If we have tool calls in this chunk, OR if we have seen them + // previously in the stream, the finish_reason MUST be "tool_calls" + // if the provider signals a stop. This ensures the client executes tools. + let mut finish_reason = candidate.finish_reason.as_ref().map(|fr| { + match fr.as_str() { + "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. if !content.is_empty() || reasoning_content.is_some() || tool_calls.is_some() || stream_usage.is_some() {