diff --git a/src/dashboard/providers.rs b/src/dashboard/providers.rs index 5713a3e8..4770a23e 100644 --- a/src/dashboard/providers.rs +++ b/src/dashboard/providers.rs @@ -362,6 +362,7 @@ pub(super) async fn handle_test_provider( messages: vec![crate::models::UnifiedMessage { role: "user".to_string(), content: vec![crate::models::ContentPart::Text { text: "Hi".to_string() }], + reasoning_content: None, tool_calls: None, name: None, tool_call_id: None, diff --git a/src/models/mod.rs b/src/models/mod.rs index 491edcea..a56fa8da 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -223,6 +223,7 @@ pub struct UnifiedRequest { pub struct UnifiedMessage { pub role: String, pub content: Vec, + pub reasoning_content: Option, pub tool_calls: Option>, pub name: Option, pub tool_call_id: Option, @@ -337,6 +338,7 @@ impl TryFrom for UnifiedRequest { UnifiedMessage { role: msg.role, content, + reasoning_content: msg.reasoning_content, tool_calls: msg.tool_calls, name: msg.name, tool_call_id: msg.tool_call_id, diff --git a/src/providers/gemini.rs b/src/providers/gemini.rs index 5a642523..c9d3b4ab 100644 --- a/src/providers/gemini.rs +++ b/src/providers/gemini.rs @@ -55,6 +55,8 @@ struct GeminiPart { function_call: Option, #[serde(skip_serializing_if = "Option::is_none")] function_response: Option, + #[serde(skip_serializing_if = "Option::is_none")] + thought: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -64,9 +66,12 @@ struct GeminiInlineData { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] struct GeminiFunctionCall { name: String, args: Value, + #[serde(skip_serializing_if = "Option::is_none")] + thought_signature: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -225,6 +230,7 @@ impl GeminiProvider { inline_data: None, function_call: None, function_response: None, + thought: None, }); } } @@ -263,39 +269,64 @@ impl GeminiProvider { name, response: response_value, }), + thought: None, }); - } else if msg.role == "assistant" && msg.tool_calls.is_some() { - // Assistant messages with tool_calls - if let Some(tool_calls) = &msg.tool_calls { - for p in &msg.content { - if let ContentPart::Text { text } = p { - if !text.trim().is_empty() { - parts.push(GeminiPart { - text: Some(text.clone()), - inline_data: None, - function_call: None, - function_response: None, - }); - } + } else if msg.role == "assistant" { + // Assistant messages: handle text, thought (reasoning), and tool_calls + for p in &msg.content { + if let ContentPart::Text { text } = p { + if !text.trim().is_empty() { + parts.push(GeminiPart { + text: Some(text.clone()), + inline_data: None, + function_call: None, + function_response: None, + thought: None, + }); } } + } + // If reasoning_content is present, include it as a 'thought' part + if let Some(reasoning) = &msg.reasoning_content { + if !reasoning.trim().is_empty() { + parts.push(GeminiPart { + text: None, + inline_data: None, + function_call: None, + function_response: None, + thought: Some(reasoning.clone()), + }); + } + } + + if let Some(tool_calls) = &msg.tool_calls { for tc in tool_calls { let args = serde_json::from_str::(&tc.function.arguments) .unwrap_or_else(|_| serde_json::json!({})); + + // RESTORE: Use tc.id as thought_signature if it was originally one + let thought_signature = if tc.id.starts_with("sig_") || !tc.id.contains('-') { + Some(tc.id.clone()) + } else { + None + }; + parts.push(GeminiPart { text: None, inline_data: None, function_call: Some(GeminiFunctionCall { name: tc.function.name.clone(), args, + thought_signature, }), function_response: None, + thought: None, }); } } } else { - // Regular text/image messages + // Regular text/image messages (mostly user) for part in msg.content { match part { ContentPart::Text { text } => { @@ -305,6 +336,7 @@ impl GeminiProvider { inline_data: None, function_call: None, function_response: None, + thought: None, }); } } @@ -322,6 +354,7 @@ impl GeminiProvider { }), function_call: None, function_response: None, + thought: None, }); } } @@ -333,7 +366,6 @@ impl GeminiProvider { } // STRATEGY: Strictly enforce alternating roles. - // If current message has the same role as the last one, merge their parts. if let Some(last_content) = contents.last_mut() { if last_content.role.as_ref() == Some(&role) { last_content.parts.extend(parts); @@ -357,13 +389,13 @@ impl GeminiProvider { inline_data: None, function_call: None, function_response: None, + thought: None, }], }); } } // Final check: ensure we don't have empty contents after filtering. - // If the last message was merged or filtered, we might have an empty array. if contents.is_empty() && system_parts.is_empty() { return Err(AppError::ProviderError("No valid content parts after filtering".to_string())); } @@ -485,13 +517,18 @@ impl GeminiProvider { let calls: Vec = parts .iter() .filter_map(|p| p.function_call.as_ref()) - .map(|fc| ToolCall { - id: format!("call_{}", Uuid::new_v4().simple()), - call_type: "function".to_string(), - function: FunctionCall { - name: fc.name.clone(), - arguments: serde_json::to_string(&fc.args).unwrap_or_else(|_| "{}".to_string()), - }, + .map(|fc| { + // CAPTURE: Use thought_signature as the ID if available + let id = fc.thought_signature.clone().unwrap_or_else(|| format!("call_{}", Uuid::new_v4().simple())); + + ToolCall { + id, + call_type: "function".to_string(), + function: FunctionCall { + name: fc.name.clone(), + arguments: serde_json::to_string(&fc.args).unwrap_or_else(|_| "{}".to_string()), + }, + } }) .collect(); @@ -504,14 +541,19 @@ impl GeminiProvider { .iter() .filter_map(|p| p.function_call.as_ref()) .enumerate() - .map(|(i, fc)| ToolCallDelta { - index: i as u32, - id: Some(format!("call_{}", Uuid::new_v4().simple())), - 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())), - }), + .map(|(i, fc)| { + // CAPTURE: Use thought_signature as the ID if available + let id = fc.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(); @@ -648,11 +690,15 @@ impl super::Provider for GeminiProvider { let candidate = gemini_response.candidates.first(); - // Extract text content (may be absent if only function calls) + // Extract text content let content = candidate .and_then(|c| c.content.parts.iter().find_map(|p| p.text.clone())) .unwrap_or_default(); + // Extract reasoning (Gemini 3 'thought' parts) + let reasoning_content = candidate + .and_then(|c| c.content.parts.iter().find_map(|p| p.thought.clone())); + // Extract function calls → OpenAI tool_calls let tool_calls = candidate.and_then(|c| Self::extract_tool_calls(&c.content.parts)); @@ -679,7 +725,7 @@ impl super::Provider for GeminiProvider { Ok(ProviderResponse { content, - reasoning_content: None, + reasoning_content, tool_calls, prompt_tokens, completion_tokens, @@ -821,6 +867,11 @@ impl super::Provider for GeminiProvider { .find_map(|p| p.text.clone()) .unwrap_or_default(); + let reasoning_content = content_obj + .parts + .iter() + .find_map(|p| p.thought.clone()); + let tool_calls = Self::extract_tool_call_deltas(&content_obj.parts); // Determine finish_reason @@ -832,10 +883,10 @@ impl super::Provider for GeminiProvider { }); // Avoid emitting completely empty chunks unless they carry usage. - if !content.is_empty() || tool_calls.is_some() || stream_usage.is_some() { + if !content.is_empty() || reasoning_content.is_some() || tool_calls.is_some() || stream_usage.is_some() { yield ProviderStreamChunk { content, - reasoning_content: None, + reasoning_content, finish_reason, tool_calls, model: model.clone(),