fix(gemini): resolve compilation errors and enable Gemini 3 reasoning
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

This commit is contained in:
2026-03-05 16:17:48 +00:00
parent 0dd6212f0a
commit a75c10bcd8
3 changed files with 89 additions and 35 deletions

View File

@@ -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,

View File

@@ -223,6 +223,7 @@ pub struct UnifiedRequest {
pub struct UnifiedMessage {
pub role: String,
pub content: Vec<ContentPart>,
pub reasoning_content: Option<String>,
pub tool_calls: Option<Vec<ToolCall>>,
pub name: Option<String>,
pub tool_call_id: Option<String>,
@@ -337,6 +338,7 @@ impl TryFrom<ChatCompletionRequest> 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,

View File

@@ -55,6 +55,8 @@ struct GeminiPart {
function_call: Option<GeminiFunctionCall>,
#[serde(skip_serializing_if = "Option::is_none")]
function_response: Option<GeminiFunctionResponse>,
#[serde(skip_serializing_if = "Option::is_none")]
thought: Option<String>,
}
#[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<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -225,6 +230,7 @@ impl GeminiProvider {
inline_data: None,
function_call: None,
function_response: None,
thought: None,
});
}
}
@@ -263,10 +269,10 @@ 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 {
} 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() {
@@ -275,27 +281,52 @@ impl GeminiProvider {
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::<Value>(&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<ToolCall> = parts
.iter()
.filter_map(|p| p.function_call.as_ref())
.map(|fc| ToolCall {
id: format!("call_{}", Uuid::new_v4().simple()),
.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 {
.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(format!("call_{}", Uuid::new_v4().simple())),
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(),