feat: support latest Gemini models (#10630)

Co-authored-by: Douwe M Osinga <douwe@sidewalklabs.com>
This commit is contained in:
Douwe Osinga
2026-07-23 18:10:45 +02:00
committed by GitHub
parent 33a976cfe3
commit d17d65f2f3
3 changed files with 207 additions and 29 deletions
+199 -28
View File
@@ -13,6 +13,7 @@ use uuid::Uuid;
use crate::conversation::message::{Message, MessageContent, ProviderMetadata};
use serde_json::{json, Map, Value};
use std::collections::HashMap;
use std::ops::Deref;
pub const THOUGHT_SIGNATURE_KEY: &str = "thoughtSignature";
@@ -53,17 +54,26 @@ fn maybe_insert_signature_from_metadata(
}
}
fn build_function_response_part(name: &str, text: String) -> Map<String, Value> {
fn build_function_response_part(
id: &str,
name: &str,
text: String,
media: Vec<Value>,
) -> Map<String, Value> {
let mut part = Map::new();
let mut function_response = Map::new();
function_response.insert("id".to_string(), json!(id));
function_response.insert("name".to_string(), json!(name));
function_response.insert("response".to_string(), json!({"content": {"text": text}}));
if !media.is_empty() {
function_response.insert("parts".to_string(), json!(media));
}
part.insert("functionResponse".to_string(), json!(function_response));
part
}
/// Convert internal Message format to Google's API message specification
pub fn format_messages(messages: &[Message]) -> Vec<Value> {
pub fn format_messages(messages: &[Message], nested_function_response_media: bool) -> Vec<Value> {
let filtered: Vec<_> = messages
.iter()
.filter(|m| m.is_agent_visible())
@@ -77,6 +87,19 @@ pub fn format_messages(messages: &[Message]) -> Vec<Value> {
})
.collect();
let tool_names: HashMap<_, _> = filtered
.iter()
.flat_map(|message| &message.content)
.filter_map(|content| match content {
MessageContent::ToolRequest(request) => request
.tool_call
.as_ref()
.ok()
.map(|tool_call| (request.id.as_str(), sanitize_function_name(&tool_call.name))),
_ => None,
})
.collect();
let active_loop_start_idx = filtered
.iter()
.enumerate()
@@ -109,6 +132,7 @@ pub fn format_messages(messages: &[Message]) -> Vec<Value> {
MessageContent::ToolRequest(request) => match &request.tool_call {
Ok(tool_call) => {
let mut function_call_part = Map::new();
function_call_part.insert("id".to_string(), json!(request.id));
function_call_part.insert(
"name".to_string(),
json!(sanitize_function_name(&tool_call.name)),
@@ -145,15 +169,25 @@ pub fn format_messages(messages: &[Message]) -> Vec<Value> {
MessageContent::ToolResponse(response) => match &response.tool_result {
Ok(result) => {
let mut tool_content = Vec::new();
let mut media = Vec::new();
for content in result.content.iter().map(|c| c.raw.clone()) {
match content {
RawContent::Image(image) => {
parts.push(json!({
"inline_data": {
"mime_type": image.mime_type,
"data": image.data,
}
}));
if nested_function_response_media {
media.push(json!({
"inlineData": {
"mimeType": image.mime_type,
"data": image.data,
}
}));
} else {
parts.push(json!({
"inline_data": {
"mime_type": image.mime_type,
"data": image.data,
}
}));
}
}
_ => {
tool_content.push(content.no_annotation());
@@ -175,15 +209,28 @@ pub fn format_messages(messages: &[Message]) -> Vec<Value> {
if text.is_empty() {
text = "Tool call is done.".to_string();
}
let mut part = build_function_response_part(&response.id, text);
let name = tool_names
.get(response.id.as_str())
.map(String::as_str)
.unwrap_or(response.id.as_str());
let mut part =
build_function_response_part(&response.id, name, text, media);
if include_signature {
maybe_insert_signature_from_metadata(&mut part, &response.metadata);
}
parts.push(json!(part));
}
Err(e) => {
let mut part =
build_function_response_part(&response.id, format!("Error: {}", e));
let name = tool_names
.get(response.id.as_str())
.map(String::as_str)
.unwrap_or(response.id.as_str());
let mut part = build_function_response_part(
&response.id,
name,
format!("Error: {}", e),
Vec::new(),
);
if include_signature {
maybe_insert_signature_from_metadata(&mut part, &response.metadata);
}
@@ -268,7 +315,11 @@ fn process_response_part_impl(
);
None
} else if let Some(function_call) = part.get("functionCall") {
let id = Uuid::new_v4().to_string();
let id = function_call
.get("id")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| Uuid::new_v4().to_string());
let name = function_call["name"].as_str().unwrap_or_default();
if !is_valid_function_name(name) {
@@ -512,7 +563,9 @@ struct GenerationConfig {
#[derive(Serialize)]
#[serde(rename_all = "lowercase")]
enum ThinkingLevel {
Minimal,
Low,
Medium,
High,
}
@@ -544,6 +597,14 @@ fn get_thinking_config(
if model_config.reasoning == Some(false)
|| model_config.thinking_effort() == Some(ThinkingEffort::Off)
{
let model_name = model_config.model_name.to_lowercase();
if model_name.starts_with("gemini-3.5") || model_name.starts_with("gemini-3.6") {
return Some(ThinkingConfig {
thinking_level: Some(ThinkingLevel::Minimal),
thinking_budget: None,
include_thoughts: false,
});
}
// Gemini 2.5 Flash defaults to dynamic thinking; only an explicit budget
// of 0 turns it off. Other families can't be disabled, so leave them unset.
if model_config
@@ -574,9 +635,9 @@ fn get_thinking_config(
return None;
}
let thinking_level = match effort {
ThinkingEffort::Off | ThinkingEffort::Low | ThinkingEffort::Medium => {
ThinkingLevel::Low
}
ThinkingEffort::Off | ThinkingEffort::Low => ThinkingLevel::Low,
ThinkingEffort::Medium if model_name.starts_with("gemini-3-pro") => ThinkingLevel::Low,
ThinkingEffort::Medium => ThinkingLevel::Medium,
ThinkingEffort::High | ThinkingEffort::Max => ThinkingLevel::High,
};
@@ -645,9 +706,15 @@ fn create_request_impl(
};
let thinking_config = get_thinking_config(model_config, thinking_budget);
let temperature = (!model_config
.model_name
.to_lowercase()
.starts_with("gemini-3"))
.then(|| model_config.temperature.map(|t| t as f64))
.flatten();
let generation_config = Some(GenerationConfig {
temperature: model_config.temperature.map(|t| t as f64),
temperature,
max_output_tokens: Some(model_config.max_output_tokens()),
thinking_config,
});
@@ -656,7 +723,13 @@ fn create_request_impl(
system_instruction: SystemInstruction {
parts: [TextPart { text: system }],
},
contents: format_messages(messages),
contents: format_messages(
messages,
model_config
.model_name
.to_lowercase()
.starts_with("gemini-3"),
),
tools: tools_wrapper,
generation_config,
};
@@ -750,7 +823,7 @@ mod tests {
set_up_text_message("Hello", Role::User),
set_up_text_message("World", Role::Assistant),
];
let payload = format_messages(&messages);
let payload = format_messages(&messages, false);
assert_eq!(payload.len(), 2);
assert_eq!(payload[0]["role"], "user");
assert_eq!(payload[0]["parts"][0]["text"], "Hello");
@@ -775,7 +848,7 @@ mod tests {
MessageContent::Image(image.no_annotation()),
],
)];
let payload = format_messages(&messages);
let payload = format_messages(&messages, false);
assert_eq!(payload.len(), 1);
assert_eq!(payload[0]["role"], "user");
@@ -805,9 +878,10 @@ mod tests {
CallToolRequestParams::new("tool_name_2").with_arguments(object(arguments.clone())),
),
];
let payload = format_messages(&messages);
let payload = format_messages(&messages, false);
assert_eq!(payload.len(), 1);
assert_eq!(payload[0]["role"], "user");
assert_eq!(payload[0]["parts"][0]["functionCall"]["id"], "id");
assert_eq!(payload[0]["parts"][0]["functionCall"]["args"], arguments);
}
@@ -815,19 +889,74 @@ mod tests {
fn test_message_to_google_spec_tool_result_message() {
let tool_result: Vec<Content> = vec![Content::text("Hello")];
let messages = vec![set_up_tool_response_message("response_id", tool_result)];
let payload = format_messages(&messages);
let payload = format_messages(&messages, false);
assert_eq!(payload.len(), 1);
assert_eq!(payload[0]["role"], "model");
assert_eq!(
payload[0]["parts"][0]["functionResponse"]["name"],
"response_id"
);
assert_eq!(
payload[0]["parts"][0]["functionResponse"]["id"],
"response_id"
);
assert_eq!(
payload[0]["parts"][0]["functionResponse"]["response"]["content"]["text"],
"Hello"
);
}
#[test]
fn test_function_response_matches_function_call() {
let messages = vec![
set_up_tool_request_message("call_123", CallToolRequestParams::new("read_file")),
set_up_tool_response_message("call_123", vec![Content::text("contents")]),
];
let payload = format_messages(&messages, false);
assert_eq!(
payload[1]["parts"][0]["functionResponse"],
json!({
"id": "call_123",
"name": "read_file",
"response": {"content": {"text": "contents"}}
})
);
}
#[test]
fn test_image_tool_result_is_nested_in_function_response() {
let messages = vec![
set_up_tool_request_message("call_123", CallToolRequestParams::new("screenshot")),
set_up_tool_response_message(
"call_123",
vec![
Content::text("Screenshot captured"),
Content::image("base64encodeddata", "image/png"),
],
),
];
let payload = format_messages(&messages, true);
assert_eq!(payload[1]["parts"].as_array().unwrap().len(), 1);
assert_eq!(
payload[1]["parts"][0]["functionResponse"],
json!({
"id": "call_123",
"name": "screenshot",
"response": {"content": {"text": "Screenshot captured"}},
"parts": [{
"inlineData": {
"mimeType": "image/png",
"data": "base64encodeddata"
}
}]
})
);
}
#[test]
fn test_message_to_google_spec_tool_result_multiple_texts() {
let tool_result: Vec<Content> = vec![
@@ -837,13 +966,14 @@ mod tests {
];
let messages = vec![set_up_tool_response_message("response_id", tool_result)];
let payload = format_messages(&messages);
let payload = format_messages(&messages, false);
let expected_payload = vec![json!({
"role": "model",
"parts": [
{
"functionResponse": {
"id": "response_id",
"name": "response_id",
"response": {
"content": {
@@ -962,6 +1092,7 @@ mod tests {
"content": {
"parts": [{
"functionCall": {
"id": "call_123",
"name": "valid_name",
"args": {
"param": "value"
@@ -974,6 +1105,7 @@ mod tests {
let message = response_to_message(response).unwrap();
assert_eq!(message.role, Role::Assistant);
assert_eq!(message.content.len(), 1);
assert_eq!(message.content[0].as_tool_request().unwrap().id, "call_123");
if let Ok(tool_call) = &message.content[0].as_tool_request().unwrap().tool_call {
assert_eq!(tool_call.name, "valid_name");
assert_eq!(
@@ -994,13 +1126,14 @@ mod tests {
let tool_result: Vec<Content> = Vec::new();
let messages = vec![set_up_tool_response_message("response_id", tool_result)];
let payload = format_messages(&messages);
let payload = format_messages(&messages, false);
let expected_payload = vec![json!({
"role": "model",
"parts": [
{
"functionResponse": {
"id": "response_id",
"name": "response_id",
"response": {
"content": {
@@ -1080,8 +1213,10 @@ mod tests {
req1.metadata.as_ref(),
);
let user_prompt = set_up_text_message("List files", Role::User);
let google_out =
format_messages(&[user_prompt.clone(), native.clone(), tool_response.clone()]);
let google_out = format_messages(
&[user_prompt.clone(), native.clone(), tool_response.clone()],
false,
);
assert_eq!(google_out[1]["parts"][0]["thoughtSignature"], SIG);
assert_eq!(google_out[2]["parts"][0]["thoughtSignature"], SIG);
@@ -1090,7 +1225,10 @@ mod tests {
"thoughtSignature": "sig_456"
})]))
.unwrap();
let google_multi = format_messages(&[user_prompt, native, tool_response, second_assistant]);
let google_multi = format_messages(
&[user_prompt, native, tool_response, second_assistant],
false,
);
assert_eq!(google_multi[1]["parts"][0]["thoughtSignature"], SIG);
assert_eq!(google_multi[2]["parts"][0]["thoughtSignature"], SIG);
assert_eq!(google_multi[3]["parts"][0]["thoughtSignature"], "sig_456");
@@ -1133,7 +1271,7 @@ mod tests {
})]))
.unwrap();
let formatted = format_messages(&[user_prompt, thinking_only, reasoning_only]);
let formatted = format_messages(&[user_prompt, thinking_only, reasoning_only], false);
assert_eq!(formatted.len(), 1);
assert_eq!(formatted[0]["role"], "user");
assert_eq!(formatted[0]["parts"][0]["text"], "hello");
@@ -1147,7 +1285,7 @@ mod tests {
})]))
.unwrap();
let formatted = format_messages(&[user_prompt, assistant_tool]);
let formatted = format_messages(&[user_prompt, assistant_tool], false);
assert_eq!(
formatted[1]["parts"][0][THOUGHT_SIGNATURE_KEY],
SYNTHETIC_THOUGHT_SIGNATURE
@@ -1446,6 +1584,15 @@ data: [DONE]"#;
let config = ModelConfig::new("gemini-2.5-pro").with_thinking_effort(ThinkingEffort::Off);
assert!(get_thinking_config(&config, None).is_none());
let config =
ModelConfig::new("gemini-3.5-flash-lite").with_thinking_effort(ThinkingEffort::Off);
let thinking_config = get_thinking_config(&config, None).unwrap();
assert!(matches!(
thinking_config.thinking_level,
Some(ThinkingLevel::Minimal)
));
assert!(!thinking_config.include_thoughts);
}
#[test]
@@ -1464,6 +1611,22 @@ data: [DONE]"#;
assert!(thinking_config.thinking_budget.is_none());
assert!(thinking_config.include_thoughts);
let config =
ModelConfig::new("gemini-3.6-flash").with_thinking_effort(ThinkingEffort::Medium);
let thinking_config = get_thinking_config(&config, None).unwrap();
assert!(matches!(
thinking_config.thinking_level,
Some(ThinkingLevel::Medium)
));
let config =
ModelConfig::new("gemini-3-pro-preview").with_thinking_effort(ThinkingEffort::Medium);
let thinking_config = get_thinking_config(&config, None).unwrap();
assert!(matches!(
thinking_config.thinking_level,
Some(ThinkingLevel::Low)
));
// Test 2: Gemini 3 model with high thinking effort
let mut params = std::collections::HashMap::new();
params.insert("thinking_effort".to_string(), serde_json::json!("high"));
@@ -1515,4 +1678,12 @@ data: [DONE]"#;
let result = get_thinking_config(&config, None);
assert!(result.is_none());
}
#[test]
fn test_gemini_3_request_omits_temperature() {
let config = ModelConfig::new("gemini-3.6-flash").with_temperature(Some(0.2));
let payload = create_request(&config, "system", &[], &[]).unwrap();
assert!(payload["generationConfig"].get("temperature").is_none());
}
}
+3
View File
@@ -26,6 +26,9 @@ pub const GOOGLE_API_HOST: &str = "https://generativelanguage.googleapis.com";
pub const GOOGLE_DEFAULT_MODEL: &str = "gemini-2.5-pro";
pub const GOOGLE_DEFAULT_FAST_MODEL: &str = "gemini-2.5-flash";
pub const GOOGLE_KNOWN_MODELS: &[&str] = &[
"gemini-3.6-flash",
"gemini-3.5-flash",
"gemini-3.5-flash-lite",
// Gemini 3 models
"gemini-3-pro-preview",
"gemini-3-pro-image-preview",
@@ -84,7 +84,11 @@ function getProviders(): ProviderConfig[] {
},
{
provider: 'google',
models: [{ name: 'gemini-2.5-flash', flaky: true }, 'gemini-3.5-flash'],
models: [
'gemini-3.5-flash',
'gemini-3.5-flash-lite',
'gemini-3.6-flash',
],
available: () => hasEnv('GOOGLE_API_KEY'),
},
{