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