Skip to main content

mentra_provider/gemini/
model.rs

1use std::collections::BTreeMap;
2
3use base64::{Engine as _, engine::general_purpose::STANDARD};
4use serde::{Deserialize, Serialize};
5use serde_json::{Value, json};
6
7use crate::{
8    BuiltinProvider, ContentBlock, ImageSource, Message, ModelInfo, ProviderError,
9    ProviderToolKind, ReasoningEffort, Request, Role, ToolChoice, ToolLoadingPolicy,
10    ToolSearchMode, ToolSpec,
11};
12
13#[derive(Deserialize)]
14pub(crate) struct GeminiModelsPage {
15    #[serde(default)]
16    pub(crate) models: Vec<GeminiModel>,
17    #[serde(default, rename = "nextPageToken", alias = "next_page_token")]
18    pub(crate) next_page_token: Option<String>,
19}
20
21#[derive(Deserialize)]
22pub(crate) struct GeminiModel {
23    pub(crate) name: String,
24    #[serde(default, rename = "baseModelId", alias = "base_model_id")]
25    pub(crate) base_model_id: Option<String>,
26    #[serde(default, rename = "displayName", alias = "display_name")]
27    pub(crate) display_name: Option<String>,
28    #[serde(default)]
29    pub(crate) description: Option<String>,
30    #[serde(
31        default,
32        rename = "supportedGenerationMethods",
33        alias = "supported_generation_methods"
34    )]
35    supported_generation_methods: Vec<String>,
36    #[serde(default, rename = "inputTokenLimit", alias = "input_token_limit")]
37    pub(crate) input_token_limit: Option<usize>,
38}
39
40impl GeminiModel {
41    pub(crate) fn supports_generate_content(&self) -> bool {
42        self.supported_generation_methods
43            .iter()
44            .any(|method| matches!(method.as_str(), "generateContent" | "streamGenerateContent"))
45    }
46}
47
48impl From<GeminiModel> for ModelInfo {
49    fn from(model: GeminiModel) -> Self {
50        let id = model.base_model_id.unwrap_or_else(|| {
51            model
52                .name
53                .strip_prefix("models/")
54                .unwrap_or(&model.name)
55                .to_string()
56        });
57
58        ModelInfo {
59            id,
60            provider: BuiltinProvider::Gemini.into(),
61            display_name: model.display_name,
62            description: model.description,
63            created_at: None,
64            context_window: model.input_token_limit,
65        }
66    }
67}
68
69#[derive(Serialize)]
70pub(crate) struct GeminiGenerateContentRequest {
71    #[serde(rename = "systemInstruction", skip_serializing_if = "Option::is_none")]
72    system_instruction: Option<GeminiInstruction>,
73    contents: Vec<GeminiContent>,
74    #[serde(skip_serializing_if = "Vec::is_empty")]
75    tools: Vec<GeminiTool>,
76    #[serde(rename = "toolConfig", skip_serializing_if = "Option::is_none")]
77    tool_config: Option<GeminiToolConfig>,
78    #[serde(rename = "generationConfig", skip_serializing_if = "Option::is_none")]
79    generation_config: Option<GeminiGenerationConfig>,
80}
81
82impl<'a> TryFrom<Request<'a>> for GeminiGenerateContentRequest {
83    type Error = ProviderError;
84
85    fn try_from(value: Request<'a>) -> Result<Self, Self::Error> {
86        let generation_config = GeminiGenerationConfig::from_request(&value)?;
87        let tool_name_by_id = collect_tool_name_by_id(value.messages.as_ref());
88        let contents = value
89            .messages
90            .iter()
91            .map(|message| GeminiContent::try_from_message(message, &tool_name_by_id))
92            .collect::<Result<Vec<_>, _>>()?
93            .into_iter()
94            .filter(|content| !content.parts.is_empty())
95            .collect::<Vec<_>>();
96        validate_gemini_tools(
97            value.tools.as_ref(),
98            value.tool_choice.as_ref(),
99            value.provider_request_options.tool_search_mode,
100        )?;
101        let tools = if value.tools.is_empty() {
102            Vec::new()
103        } else {
104            vec![GeminiTool {
105                function_declarations: value
106                    .tools
107                    .iter()
108                    .map(GeminiFunctionDeclaration::from)
109                    .collect(),
110            }]
111        };
112
113        Ok(GeminiGenerateContentRequest {
114            system_instruction: value.system.map(|system| GeminiInstruction {
115                parts: vec![GeminiPart::Text {
116                    text: system.into_owned(),
117                }],
118            }),
119            contents,
120            tool_config: value
121                .tool_choice
122                .filter(|_| !tools.is_empty())
123                .map(Into::into),
124            tools,
125            generation_config,
126        })
127    }
128}
129
130fn validate_gemini_tools(
131    tools: &[ToolSpec],
132    tool_choice: Option<&ToolChoice>,
133    tool_search_mode: ToolSearchMode,
134) -> Result<(), ProviderError> {
135    if let Some(tool) = tools
136        .iter()
137        .find(|tool| tool.kind != ProviderToolKind::Function)
138    {
139        return Err(ProviderError::InvalidRequest(format!(
140            "Gemini does not support provider tool kind {:?} for '{}'",
141            tool.kind, tool.name
142        )));
143    }
144
145    let forced_tool_name = match tool_choice {
146        Some(ToolChoice::Tool { name }) => Some(name.as_str()),
147        _ => None,
148    };
149
150    let has_deferred_tools = tools.iter().any(|tool| {
151        tool.loading_policy == ToolLoadingPolicy::Deferred
152            && forced_tool_name != Some(tool.name.as_str())
153    });
154
155    if !has_deferred_tools {
156        return Ok(());
157    }
158
159    let message = match tool_search_mode {
160        ToolSearchMode::Hosted => {
161            "Gemini does not support hosted tool search for deferred custom tools"
162        }
163        ToolSearchMode::Disabled => {
164            "Gemini does not support deferred custom tools without hosted tool search"
165        }
166    };
167
168    Err(ProviderError::InvalidRequest(message.to_string()))
169}
170
171fn collect_tool_name_by_id(messages: &[Message]) -> BTreeMap<String, String> {
172    let mut names = BTreeMap::new();
173
174    for message in messages {
175        for block in &message.content {
176            if let ContentBlock::ToolUse { id, name, .. } = block {
177                names.insert(id.clone(), name.clone());
178            }
179        }
180    }
181
182    names
183}
184
185#[derive(Serialize)]
186struct GeminiInstruction {
187    parts: Vec<GeminiPart>,
188}
189
190#[derive(Serialize)]
191struct GeminiContent {
192    role: String,
193    parts: Vec<GeminiPart>,
194}
195
196impl GeminiContent {
197    fn try_from_message(
198        message: &Message,
199        tool_name_by_id: &BTreeMap<String, String>,
200    ) -> Result<Self, ProviderError> {
201        let role = match &message.role {
202            Role::User | Role::Assistant => message.role.to_string(),
203            Role::Unknown(role) => {
204                return Err(ProviderError::InvalidRequest(format!(
205                    "Gemini message role '{role}' is not supported"
206                )));
207            }
208        };
209
210        let mut parts = Vec::with_capacity(message.content.len());
211        for block in &message.content {
212            parts.push(GeminiPart::try_from_block(
213                block,
214                &message.role,
215                tool_name_by_id,
216            )?);
217        }
218
219        Ok(GeminiContent { role, parts })
220    }
221}
222
223#[derive(Serialize)]
224#[serde(untagged)]
225enum GeminiPart {
226    Text {
227        text: String,
228    },
229    InlineData {
230        #[serde(rename = "inlineData")]
231        inline_data: GeminiInlineData,
232    },
233    FunctionCall {
234        #[serde(rename = "functionCall")]
235        function_call: GeminiFunctionCall,
236    },
237    FunctionResponse {
238        #[serde(rename = "functionResponse")]
239        function_response: GeminiFunctionResponse,
240    },
241}
242
243impl GeminiPart {
244    fn try_from_block(
245        block: &ContentBlock,
246        role: &Role,
247        tool_name_by_id: &BTreeMap<String, String>,
248    ) -> Result<Self, ProviderError> {
249        match block {
250            ContentBlock::Text { text } => Ok(GeminiPart::Text { text: text.clone() }),
251            ContentBlock::Thinking { .. } => Ok(GeminiPart::Text {
252                text: block
253                    .thinking_fallback_text()
254                    .expect("thinking block has fallback text"),
255            }),
256            ContentBlock::Image { source } => {
257                if !matches!(role, Role::User) {
258                    return Err(ProviderError::InvalidRequest(
259                        "Gemini image inputs are only supported in user messages".to_string(),
260                    ));
261                }
262
263                match source {
264                    ImageSource::Bytes { media_type, data } => Ok(GeminiPart::InlineData {
265                        inline_data: GeminiInlineData {
266                            mime_type: media_type.clone(),
267                            data: STANDARD.encode(data),
268                        },
269                    }),
270                    ImageSource::Url { .. } => Err(ProviderError::InvalidRequest(
271                        "Gemini image URL inputs are not supported without a file upload flow"
272                            .to_string(),
273                    )),
274                }
275            }
276            ContentBlock::ToolUse { name, input, .. } => Ok(GeminiPart::FunctionCall {
277                function_call: GeminiFunctionCall {
278                    name: name.clone(),
279                    args: input.clone(),
280                },
281            }),
282            ContentBlock::ToolResult {
283                tool_use_id,
284                content,
285                is_error,
286            } => {
287                let name = tool_name_by_id.get(tool_use_id).cloned().ok_or_else(|| {
288                    ProviderError::InvalidRequest(format!(
289                        "Gemini tool result references unknown tool_use_id '{tool_use_id}'"
290                    ))
291                })?;
292
293                Ok(GeminiPart::FunctionResponse {
294                    function_response: GeminiFunctionResponse {
295                        name,
296                        response: json!({
297                            "content": content.to_display_string(),
298                            "is_error": is_error,
299                        }),
300                    },
301                })
302            }
303            ContentBlock::HostedToolSearch { call } => Ok(GeminiPart::FunctionCall {
304                function_call: GeminiFunctionCall {
305                    name: "tool_search".to_string(),
306                    args: json!({ "query": call.query }),
307                },
308            }),
309            ContentBlock::HostedWebSearch { call } => Ok(GeminiPart::FunctionCall {
310                function_call: GeminiFunctionCall {
311                    name: "web_search".to_string(),
312                    args: serde_json::to_value(call.action.clone()).unwrap_or(Value::Null),
313                },
314            }),
315            ContentBlock::ImageGeneration { call } => Ok(GeminiPart::FunctionCall {
316                function_call: GeminiFunctionCall {
317                    name: "image_generation".to_string(),
318                    args: json!({
319                        "status": call.status,
320                        "revised_prompt": call.revised_prompt,
321                    }),
322                },
323            }),
324        }
325    }
326}
327
328#[derive(Serialize)]
329struct GeminiInlineData {
330    #[serde(rename = "mimeType")]
331    mime_type: String,
332    data: String,
333}
334
335#[derive(Serialize)]
336struct GeminiFunctionCall {
337    name: String,
338    args: Value,
339}
340
341#[derive(Serialize)]
342struct GeminiFunctionResponse {
343    name: String,
344    response: Value,
345}
346
347#[derive(Serialize)]
348struct GeminiTool {
349    #[serde(rename = "functionDeclarations")]
350    function_declarations: Vec<GeminiFunctionDeclaration>,
351}
352
353#[derive(Serialize)]
354struct GeminiFunctionDeclaration {
355    name: String,
356    #[serde(skip_serializing_if = "Option::is_none")]
357    description: Option<String>,
358    parameters: Value,
359}
360
361impl From<&ToolSpec> for GeminiFunctionDeclaration {
362    fn from(tool: &ToolSpec) -> Self {
363        GeminiFunctionDeclaration {
364            name: tool.name.clone(),
365            description: tool.description.clone(),
366            parameters: tool.input_schema.clone(),
367        }
368    }
369}
370
371#[derive(Serialize)]
372struct GeminiToolConfig {
373    #[serde(rename = "functionCallingConfig")]
374    function_calling_config: GeminiFunctionCallingConfig,
375}
376
377impl From<ToolChoice> for GeminiToolConfig {
378    fn from(choice: ToolChoice) -> Self {
379        let function_calling_config = match choice {
380            ToolChoice::Auto => GeminiFunctionCallingConfig {
381                mode: GeminiFunctionCallingMode::Auto,
382                allowed_function_names: Vec::new(),
383            },
384            ToolChoice::Any => GeminiFunctionCallingConfig {
385                mode: GeminiFunctionCallingMode::Any,
386                allowed_function_names: Vec::new(),
387            },
388            ToolChoice::Tool { name } => GeminiFunctionCallingConfig {
389                mode: GeminiFunctionCallingMode::Any,
390                allowed_function_names: vec![name],
391            },
392        };
393
394        GeminiToolConfig {
395            function_calling_config,
396        }
397    }
398}
399
400#[derive(Serialize)]
401struct GeminiFunctionCallingConfig {
402    mode: GeminiFunctionCallingMode,
403    #[serde(rename = "allowedFunctionNames", skip_serializing_if = "Vec::is_empty")]
404    allowed_function_names: Vec<String>,
405}
406
407#[derive(Serialize)]
408enum GeminiFunctionCallingMode {
409    #[serde(rename = "AUTO")]
410    Auto,
411    #[serde(rename = "ANY")]
412    Any,
413}
414
415#[derive(Serialize)]
416struct GeminiGenerationConfig {
417    #[serde(skip_serializing_if = "Option::is_none")]
418    temperature: Option<f32>,
419    #[serde(rename = "maxOutputTokens", skip_serializing_if = "Option::is_none")]
420    max_output_tokens: Option<u32>,
421    #[serde(rename = "thinkingConfig", skip_serializing_if = "Option::is_none")]
422    thinking_config: Option<GeminiThinkingConfig>,
423}
424
425impl GeminiGenerationConfig {
426    fn from_request(request: &Request<'_>) -> Result<Option<Self>, ProviderError> {
427        let thinking_config =
428            if let Some(reasoning) = request.provider_request_options.reasoning.as_ref() {
429                let Some(effort) = reasoning.effort else {
430                    return Ok(None);
431                };
432                if !supports_gemini_thinking_level(&request.model) {
433                    return Err(ProviderError::InvalidRequest(format!(
434                        "Gemini reasoning effort requires a Gemini 3 model, got '{}'",
435                        request.model
436                    )));
437                }
438
439                Some(GeminiThinkingConfig {
440                    thinking_level: effort.try_into()?,
441                })
442            } else {
443                None
444            };
445
446        let config = GeminiGenerationConfig {
447            temperature: request.temperature,
448            max_output_tokens: request.max_output_tokens,
449            thinking_config,
450        };
451
452        Ok((!config.is_empty()).then_some(config))
453    }
454
455    fn is_empty(&self) -> bool {
456        self.temperature.is_none()
457            && self.max_output_tokens.is_none()
458            && self.thinking_config.is_none()
459    }
460}
461
462#[derive(Serialize)]
463struct GeminiThinkingConfig {
464    #[serde(rename = "thinkingLevel")]
465    thinking_level: GeminiThinkingLevel,
466}
467
468#[derive(Serialize)]
469#[serde(rename_all = "snake_case")]
470enum GeminiThinkingLevel {
471    Low,
472    Medium,
473    High,
474}
475
476impl TryFrom<ReasoningEffort> for GeminiThinkingLevel {
477    type Error = ProviderError;
478
479    fn try_from(value: ReasoningEffort) -> Result<Self, Self::Error> {
480        match value {
481            ReasoningEffort::Low => Ok(Self::Low),
482            ReasoningEffort::Medium => Ok(Self::Medium),
483            ReasoningEffort::High => Ok(Self::High),
484            ReasoningEffort::XHigh => Err(ProviderError::InvalidRequest(
485                "Gemini does not support reasoning effort 'xhigh'".to_string(),
486            )),
487            ReasoningEffort::Max => Err(ProviderError::InvalidRequest(
488                "Gemini does not support reasoning effort 'max'".to_string(),
489            )),
490        }
491    }
492}
493
494fn supports_gemini_thinking_level(model: &str) -> bool {
495    let model = model.strip_prefix("models/").unwrap_or(model);
496    model.starts_with("gemini-3")
497}
498
499#[cfg(test)]
500mod tests {
501    use std::{borrow::Cow, collections::BTreeMap};
502
503    use serde_json::json;
504
505    use crate::{
506        BuiltinProvider, ContentBlock, Message, ModelInfo, ProviderError, ProviderRequestOptions,
507        ReasoningEffort, ReasoningOptions, Request, Role, ToolChoice, ToolLoadingPolicy,
508        ToolResultContent, ToolSearchMode, ToolSpec,
509    };
510
511    use super::{GeminiGenerateContentRequest, GeminiModel};
512
513    #[test]
514    fn a_listed_model_carries_the_input_token_limit_gemini_reports() {
515        // Gemini is the one listing of the three that states a limit, and it is
516        // what lets a compaction threshold be a fraction of the window rather
517        // than a constant that is wrong for most models.
518        let listed: super::GeminiModel = serde_json::from_value(json!({
519            "name": "models/gemini-2.5-pro",
520            "displayName": "Gemini 2.5 Pro",
521            "supportedGenerationMethods": ["generateContent"],
522            "inputTokenLimit": 1_048_576,
523        }))
524        .expect("model parses");
525
526        let info = ModelInfo::from(listed);
527
528        assert_eq!(info.id, "gemini-2.5-pro");
529        assert_eq!(info.context_window, Some(1_048_576));
530    }
531
532    #[test]
533    fn converts_model_name_to_base_model_id() {
534        let model = GeminiModel {
535            input_token_limit: None,
536            name: "models/gemini-3-flash".to_string(),
537            base_model_id: Some("gemini-3-flash".to_string()),
538            display_name: Some("Gemini 3 Flash".to_string()),
539            description: Some("Test".to_string()),
540            supported_generation_methods: vec!["generateContent".to_string()],
541        };
542
543        let info = ModelInfo::from(model);
544
545        assert_eq!(info.id, "gemini-3-flash");
546        assert_eq!(info.provider, BuiltinProvider::Gemini.into());
547        assert_eq!(info.display_name.as_deref(), Some("Gemini 3 Flash"));
548    }
549
550    #[test]
551    fn converts_request_to_gemini_payload() {
552        let request = Request {
553            model: Cow::Borrowed("gemini-2.0-flash"),
554            system: Some(Cow::Borrowed("Be helpful.")),
555            messages: Cow::Owned(vec![
556                Message::user(ContentBlock::text("What files changed?")),
557                Message::assistant(ContentBlock::ToolUse {
558                    id: "call_1".to_string(),
559                    name: "files".to_string(),
560                    input: json!({ "operations": [{ "op": "read", "path": "README.md" }] }),
561                }),
562                Message::user(ContentBlock::ToolResult {
563                    tool_use_id: "call_1".to_string(),
564                    content: ToolResultContent::text("README contents"),
565                    is_error: false,
566                }),
567            ]),
568            tools: Cow::Owned(vec![ToolSpec {
569                name: "files".to_string(),
570                description: Some("Read and edit files".to_string()),
571                input_schema: json!({
572                    "type": "object",
573                    "properties": {
574                        "operations": { "type": "array" }
575                    }
576                }),
577                output_schema: None,
578                kind: crate::ProviderToolKind::Function,
579                loading_policy: ToolLoadingPolicy::Immediate,
580                strict: None,
581                options: None,
582            }]),
583            tool_choice: Some(ToolChoice::Tool {
584                name: "files".to_string(),
585            }),
586            temperature: Some(0.2),
587            max_output_tokens: Some(256),
588            metadata: Cow::Owned(BTreeMap::from([(
589                "agent".to_string(),
590                "mentra".to_string(),
591            )])),
592            provider_request_options: ProviderRequestOptions::default(),
593        };
594
595        let payload =
596            serde_json::to_value(GeminiGenerateContentRequest::try_from(request).unwrap())
597                .expect("request should serialize");
598
599        assert_eq!(
600            payload["systemInstruction"]["parts"][0]["text"],
601            "Be helpful."
602        );
603        assert_eq!(payload["contents"][0]["role"], "user");
604        assert_eq!(
605            payload["contents"][0]["parts"][0]["text"],
606            "What files changed?"
607        );
608        assert_eq!(
609            payload["contents"][1]["parts"][0]["functionCall"]["name"],
610            "files"
611        );
612        assert_eq!(
613            payload["contents"][2]["parts"][0]["functionResponse"]["name"],
614            "files"
615        );
616        assert_eq!(
617            payload["contents"][2]["parts"][0]["functionResponse"]["response"]["content"],
618            "README contents"
619        );
620        assert_eq!(
621            payload["tools"][0]["functionDeclarations"][0]["name"],
622            "files"
623        );
624        assert_eq!(
625            payload["toolConfig"]["functionCallingConfig"]["mode"],
626            "ANY"
627        );
628        assert_eq!(
629            payload["toolConfig"]["functionCallingConfig"]["allowedFunctionNames"][0],
630            "files"
631        );
632        let temperature = payload["generationConfig"]["temperature"]
633            .as_f64()
634            .expect("temperature should be numeric");
635        assert!((temperature - 0.2).abs() < 1e-6);
636        assert_eq!(payload["generationConfig"]["maxOutputTokens"], 256);
637        assert!(payload.get("metadata").is_none());
638    }
639
640    #[test]
641    fn serializes_inline_images_into_inline_data_parts() {
642        let request = Request {
643            model: Cow::Borrowed("gemini-2.0-flash"),
644            system: None,
645            messages: Cow::Owned(vec![Message {
646                role: Role::User,
647                content: vec![
648                    ContentBlock::text("Describe this"),
649                    ContentBlock::image_bytes("image/png", [1_u8, 2, 3]),
650                ],
651            }]),
652            tools: Cow::Owned(vec![]),
653            tool_choice: Some(ToolChoice::Auto),
654            temperature: None,
655            max_output_tokens: None,
656            metadata: Cow::Owned(BTreeMap::new()),
657            provider_request_options: ProviderRequestOptions::default(),
658        };
659
660        let payload =
661            serde_json::to_value(GeminiGenerateContentRequest::try_from(request).unwrap())
662                .expect("request should serialize");
663
664        assert_eq!(payload["contents"][0]["parts"][0]["text"], "Describe this");
665        assert_eq!(
666            payload["contents"][0]["parts"][1]["inlineData"]["mimeType"],
667            "image/png"
668        );
669        assert_eq!(
670            payload["contents"][0]["parts"][1]["inlineData"]["data"],
671            "AQID"
672        );
673    }
674
675    #[test]
676    fn rejects_url_images() {
677        let request = Request {
678            model: Cow::Borrowed("gemini-2.0-flash"),
679            system: None,
680            messages: Cow::Owned(vec![Message::user(ContentBlock::image_url(
681                "https://example.com/image.png",
682            ))]),
683            tools: Cow::Owned(vec![]),
684            tool_choice: None,
685            temperature: None,
686            max_output_tokens: None,
687            metadata: Cow::Owned(BTreeMap::new()),
688            provider_request_options: ProviderRequestOptions::default(),
689        };
690
691        let error = GeminiGenerateContentRequest::try_from(request)
692            .err()
693            .expect("request should fail");
694        match error {
695            ProviderError::InvalidRequest(message) => {
696                assert!(message.contains("image URL inputs are not supported"));
697            }
698            other => panic!("unexpected error: {other:?}"),
699        }
700    }
701
702    #[test]
703    fn serializes_tool_choice_modes() {
704        let request = Request {
705            model: Cow::Borrowed("gemini-2.0-flash"),
706            system: None,
707            messages: Cow::Owned(vec![Message::user(ContentBlock::text("hi"))]),
708            tools: Cow::Owned(vec![ToolSpec {
709                name: "echo".to_string(),
710                description: None,
711                input_schema: json!({"type":"object"}),
712                output_schema: None,
713                kind: crate::ProviderToolKind::Function,
714                loading_policy: ToolLoadingPolicy::Immediate,
715                strict: None,
716                options: None,
717            }]),
718            tool_choice: Some(ToolChoice::Any),
719            temperature: None,
720            max_output_tokens: None,
721            metadata: Cow::Owned(BTreeMap::new()),
722            provider_request_options: ProviderRequestOptions::default(),
723        };
724        let any_payload =
725            serde_json::to_value(GeminiGenerateContentRequest::try_from(request).unwrap())
726                .expect("request should serialize");
727        assert_eq!(
728            any_payload["toolConfig"]["functionCallingConfig"]["mode"],
729            "ANY"
730        );
731
732        let request = Request {
733            model: Cow::Borrowed("gemini-2.0-flash"),
734            system: None,
735            messages: Cow::Owned(vec![Message::user(ContentBlock::text("hi"))]),
736            tools: Cow::Owned(vec![ToolSpec {
737                name: "echo".to_string(),
738                description: None,
739                input_schema: json!({"type":"object"}),
740                output_schema: None,
741                kind: crate::ProviderToolKind::Function,
742                loading_policy: ToolLoadingPolicy::Immediate,
743                strict: None,
744                options: None,
745            }]),
746            tool_choice: Some(ToolChoice::Auto),
747            temperature: None,
748            max_output_tokens: None,
749            metadata: Cow::Owned(BTreeMap::new()),
750            provider_request_options: ProviderRequestOptions::default(),
751        };
752        let auto_payload =
753            serde_json::to_value(GeminiGenerateContentRequest::try_from(request).unwrap())
754                .expect("request should serialize");
755        assert_eq!(
756            auto_payload["toolConfig"]["functionCallingConfig"]["mode"],
757            "AUTO"
758        );
759    }
760
761    #[test]
762    fn omits_tool_config_when_tool_choice_is_unset() {
763        let request = Request {
764            model: Cow::Borrowed("gemini-2.0-flash"),
765            system: None,
766            messages: Cow::Owned(vec![Message::user(ContentBlock::text("hi"))]),
767            tools: Cow::Owned(vec![ToolSpec {
768                name: "echo".to_string(),
769                description: None,
770                input_schema: json!({"type":"object"}),
771                output_schema: None,
772                kind: crate::ProviderToolKind::Function,
773                loading_policy: ToolLoadingPolicy::Immediate,
774                strict: None,
775                options: None,
776            }]),
777            tool_choice: None,
778            temperature: None,
779            max_output_tokens: None,
780            metadata: Cow::Owned(BTreeMap::new()),
781            provider_request_options: ProviderRequestOptions::default(),
782        };
783
784        let payload =
785            serde_json::to_value(GeminiGenerateContentRequest::try_from(request).unwrap())
786                .expect("request should serialize");
787
788        assert!(payload.get("toolConfig").is_none());
789    }
790
791    #[test]
792    fn serializes_shared_reasoning_effort_for_gemini_3_models() {
793        for (effort, expected) in [
794            (ReasoningEffort::Low, "low"),
795            (ReasoningEffort::Medium, "medium"),
796            (ReasoningEffort::High, "high"),
797        ] {
798            let request = Request {
799                model: Cow::Borrowed("gemini-3-flash-preview"),
800                system: None,
801                messages: Cow::Owned(vec![Message::user(ContentBlock::text("hi"))]),
802                tools: Cow::Owned(vec![]),
803                tool_choice: Some(ToolChoice::Auto),
804                temperature: None,
805                max_output_tokens: None,
806                metadata: Cow::Owned(BTreeMap::new()),
807                provider_request_options: ProviderRequestOptions {
808                    reasoning: Some(ReasoningOptions {
809                        effort: Some(effort),
810                        summary: None,
811                    }),
812                    ..Default::default()
813                },
814            };
815
816            let payload =
817                serde_json::to_value(GeminiGenerateContentRequest::try_from(request).unwrap())
818                    .expect("request should serialize");
819
820            assert_eq!(
821                payload["generationConfig"]["thinkingConfig"]["thinkingLevel"],
822                expected
823            );
824        }
825    }
826
827    #[test]
828    fn rejects_reasoning_effort_for_gemini_2_5_models() {
829        let request = Request {
830            model: Cow::Borrowed("gemini-2.5-flash"),
831            system: None,
832            messages: Cow::Owned(vec![Message::user(ContentBlock::text("hi"))]),
833            tools: Cow::Owned(vec![]),
834            tool_choice: Some(ToolChoice::Auto),
835            temperature: None,
836            max_output_tokens: None,
837            metadata: Cow::Owned(BTreeMap::new()),
838            provider_request_options: ProviderRequestOptions {
839                reasoning: Some(ReasoningOptions {
840                    effort: Some(ReasoningEffort::Low),
841                    summary: None,
842                }),
843                ..Default::default()
844            },
845        };
846
847        let error = GeminiGenerateContentRequest::try_from(request)
848            .err()
849            .expect("request should fail");
850        match error {
851            ProviderError::InvalidRequest(message) => {
852                assert!(message.contains("Gemini 3"));
853            }
854            other => panic!("unexpected error: {other:?}"),
855        }
856    }
857
858    #[test]
859    fn rejects_extended_reasoning_effort_for_gemini() {
860        for (effort, expected) in [
861            (ReasoningEffort::XHigh, "xhigh"),
862            (ReasoningEffort::Max, "max"),
863        ] {
864            let request = Request {
865                model: Cow::Borrowed("gemini-3-flash-preview"),
866                system: None,
867                messages: Cow::Owned(vec![Message::user(ContentBlock::text("hi"))]),
868                tools: Cow::Owned(vec![]),
869                tool_choice: Some(ToolChoice::Auto),
870                temperature: None,
871                max_output_tokens: None,
872                metadata: Cow::Owned(BTreeMap::new()),
873                provider_request_options: ProviderRequestOptions {
874                    reasoning: Some(ReasoningOptions {
875                        effort: Some(effort),
876                        summary: None,
877                    }),
878                    ..Default::default()
879                },
880            };
881
882            let error = GeminiGenerateContentRequest::try_from(request)
883                .err()
884                .expect("extended Gemini effort should fail");
885            match error {
886                ProviderError::InvalidRequest(message) => {
887                    assert!(message.contains(expected));
888                }
889                other => panic!("unexpected error: {other:?}"),
890            }
891        }
892    }
893
894    #[test]
895    fn rejects_hosted_tool_search_with_deferred_tools() {
896        let request = Request {
897            model: Cow::Borrowed("gemini-2.0-flash"),
898            system: None,
899            messages: Cow::Owned(vec![Message::user(ContentBlock::text("hi"))]),
900            tools: Cow::Owned(vec![ToolSpec {
901                name: "echo".to_string(),
902                description: None,
903                input_schema: json!({"type":"object"}),
904                output_schema: None,
905                kind: crate::ProviderToolKind::Function,
906                loading_policy: ToolLoadingPolicy::Deferred,
907                strict: None,
908                options: None,
909            }]),
910            tool_choice: Some(ToolChoice::Auto),
911            temperature: None,
912            max_output_tokens: None,
913            metadata: Cow::Owned(BTreeMap::new()),
914            provider_request_options: ProviderRequestOptions {
915                tool_search_mode: ToolSearchMode::Hosted,
916                ..Default::default()
917            },
918        };
919
920        let error = GeminiGenerateContentRequest::try_from(request)
921            .err()
922            .expect("request should fail");
923        match error {
924            ProviderError::InvalidRequest(message) => {
925                assert!(message.contains("does not support hosted tool search"));
926            }
927            other => panic!("unexpected error: {other:?}"),
928        }
929    }
930
931    #[test]
932    fn forced_deferred_tool_still_serializes_as_function_declaration() {
933        let request = Request {
934            model: Cow::Borrowed("gemini-2.0-flash"),
935            system: None,
936            messages: Cow::Owned(vec![Message::user(ContentBlock::text("hi"))]),
937            tools: Cow::Owned(vec![ToolSpec {
938                name: "echo".to_string(),
939                description: None,
940                input_schema: json!({"type":"object"}),
941                output_schema: None,
942                kind: crate::ProviderToolKind::Function,
943                loading_policy: ToolLoadingPolicy::Deferred,
944                strict: None,
945                options: None,
946            }]),
947            tool_choice: Some(ToolChoice::Tool {
948                name: "echo".to_string(),
949            }),
950            temperature: None,
951            max_output_tokens: None,
952            metadata: Cow::Owned(BTreeMap::new()),
953            provider_request_options: ProviderRequestOptions {
954                tool_search_mode: ToolSearchMode::Hosted,
955                ..Default::default()
956            },
957        };
958
959        let payload =
960            serde_json::to_value(GeminiGenerateContentRequest::try_from(request).unwrap())
961                .expect("request should serialize");
962
963        assert_eq!(
964            payload["tools"][0]["functionDeclarations"][0]["name"],
965            "echo"
966        );
967    }
968}