feat: support latest Gemini models (#10630)
Co-authored-by: Douwe M Osinga <douwe@sidewalklabs.com>
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user