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                options: None,
545            }]),
546            tool_choice: Some(ToolChoice::Tool {
547                name: "files".to_string(),
548            }),
549            temperature: Some(0.2),
550            max_output_tokens: Some(256),
551            metadata: Cow::Owned(BTreeMap::from([(
552                "agent".to_string(),
553                "mentra".to_string(),
554            )])),
555            provider_request_options: ProviderRequestOptions::default(),
556        };
557
558        let payload =
559            serde_json::to_value(GeminiGenerateContentRequest::try_from(request).unwrap())
560                .expect("request should serialize");
561
562        assert_eq!(
563            payload["systemInstruction"]["parts"][0]["text"],
564            "Be helpful."
565        );
566        assert_eq!(payload["contents"][0]["role"], "user");
567        assert_eq!(
568            payload["contents"][0]["parts"][0]["text"],
569            "What files changed?"
570        );
571        assert_eq!(
572            payload["contents"][1]["parts"][0]["functionCall"]["name"],
573            "files"
574        );
575        assert_eq!(
576            payload["contents"][2]["parts"][0]["functionResponse"]["name"],
577            "files"
578        );
579        assert_eq!(
580            payload["contents"][2]["parts"][0]["functionResponse"]["response"]["content"],
581            "README contents"
582        );
583        assert_eq!(
584            payload["tools"][0]["functionDeclarations"][0]["name"],
585            "files"
586        );
587        assert_eq!(
588            payload["toolConfig"]["functionCallingConfig"]["mode"],
589            "ANY"
590        );
591        assert_eq!(
592            payload["toolConfig"]["functionCallingConfig"]["allowedFunctionNames"][0],
593            "files"
594        );
595        let temperature = payload["generationConfig"]["temperature"]
596            .as_f64()
597            .expect("temperature should be numeric");
598        assert!((temperature - 0.2).abs() < 1e-6);
599        assert_eq!(payload["generationConfig"]["maxOutputTokens"], 256);
600        assert!(payload.get("metadata").is_none());
601    }
602
603    #[test]
604    fn serializes_inline_images_into_inline_data_parts() {
605        let request = Request {
606            model: Cow::Borrowed("gemini-2.0-flash"),
607            system: None,
608            messages: Cow::Owned(vec![Message {
609                role: Role::User,
610                content: vec![
611                    ContentBlock::text("Describe this"),
612                    ContentBlock::image_bytes("image/png", [1_u8, 2, 3]),
613                ],
614            }]),
615            tools: Cow::Owned(vec![]),
616            tool_choice: Some(ToolChoice::Auto),
617            temperature: None,
618            max_output_tokens: None,
619            metadata: Cow::Owned(BTreeMap::new()),
620            provider_request_options: ProviderRequestOptions::default(),
621        };
622
623        let payload =
624            serde_json::to_value(GeminiGenerateContentRequest::try_from(request).unwrap())
625                .expect("request should serialize");
626
627        assert_eq!(payload["contents"][0]["parts"][0]["text"], "Describe this");
628        assert_eq!(
629            payload["contents"][0]["parts"][1]["inlineData"]["mimeType"],
630            "image/png"
631        );
632        assert_eq!(
633            payload["contents"][0]["parts"][1]["inlineData"]["data"],
634            "AQID"
635        );
636    }
637
638    #[test]
639    fn rejects_url_images() {
640        let request = Request {
641            model: Cow::Borrowed("gemini-2.0-flash"),
642            system: None,
643            messages: Cow::Owned(vec![Message::user(ContentBlock::image_url(
644                "https://example.com/image.png",
645            ))]),
646            tools: Cow::Owned(vec![]),
647            tool_choice: None,
648            temperature: None,
649            max_output_tokens: None,
650            metadata: Cow::Owned(BTreeMap::new()),
651            provider_request_options: ProviderRequestOptions::default(),
652        };
653
654        let error = GeminiGenerateContentRequest::try_from(request)
655            .err()
656            .expect("request should fail");
657        match error {
658            ProviderError::InvalidRequest(message) => {
659                assert!(message.contains("image URL inputs are not supported"));
660            }
661            other => panic!("unexpected error: {other:?}"),
662        }
663    }
664
665    #[test]
666    fn serializes_tool_choice_modes() {
667        let request = Request {
668            model: Cow::Borrowed("gemini-2.0-flash"),
669            system: None,
670            messages: Cow::Owned(vec![Message::user(ContentBlock::text("hi"))]),
671            tools: Cow::Owned(vec![ToolSpec {
672                name: "echo".to_string(),
673                description: None,
674                input_schema: json!({"type":"object"}),
675                output_schema: None,
676                kind: crate::ProviderToolKind::Function,
677                loading_policy: ToolLoadingPolicy::Immediate,
678                options: None,
679            }]),
680            tool_choice: Some(ToolChoice::Any),
681            temperature: None,
682            max_output_tokens: None,
683            metadata: Cow::Owned(BTreeMap::new()),
684            provider_request_options: ProviderRequestOptions::default(),
685        };
686        let any_payload =
687            serde_json::to_value(GeminiGenerateContentRequest::try_from(request).unwrap())
688                .expect("request should serialize");
689        assert_eq!(
690            any_payload["toolConfig"]["functionCallingConfig"]["mode"],
691            "ANY"
692        );
693
694        let request = Request {
695            model: Cow::Borrowed("gemini-2.0-flash"),
696            system: None,
697            messages: Cow::Owned(vec![Message::user(ContentBlock::text("hi"))]),
698            tools: Cow::Owned(vec![ToolSpec {
699                name: "echo".to_string(),
700                description: None,
701                input_schema: json!({"type":"object"}),
702                output_schema: None,
703                kind: crate::ProviderToolKind::Function,
704                loading_policy: ToolLoadingPolicy::Immediate,
705                options: None,
706            }]),
707            tool_choice: Some(ToolChoice::Auto),
708            temperature: None,
709            max_output_tokens: None,
710            metadata: Cow::Owned(BTreeMap::new()),
711            provider_request_options: ProviderRequestOptions::default(),
712        };
713        let auto_payload =
714            serde_json::to_value(GeminiGenerateContentRequest::try_from(request).unwrap())
715                .expect("request should serialize");
716        assert_eq!(
717            auto_payload["toolConfig"]["functionCallingConfig"]["mode"],
718            "AUTO"
719        );
720    }
721
722    #[test]
723    fn omits_tool_config_when_tool_choice_is_unset() {
724        let request = Request {
725            model: Cow::Borrowed("gemini-2.0-flash"),
726            system: None,
727            messages: Cow::Owned(vec![Message::user(ContentBlock::text("hi"))]),
728            tools: Cow::Owned(vec![ToolSpec {
729                name: "echo".to_string(),
730                description: None,
731                input_schema: json!({"type":"object"}),
732                output_schema: None,
733                kind: crate::ProviderToolKind::Function,
734                loading_policy: ToolLoadingPolicy::Immediate,
735                options: None,
736            }]),
737            tool_choice: None,
738            temperature: None,
739            max_output_tokens: None,
740            metadata: Cow::Owned(BTreeMap::new()),
741            provider_request_options: ProviderRequestOptions::default(),
742        };
743
744        let payload =
745            serde_json::to_value(GeminiGenerateContentRequest::try_from(request).unwrap())
746                .expect("request should serialize");
747
748        assert!(payload.get("toolConfig").is_none());
749    }
750
751    #[test]
752    fn serializes_reasoning_effort_for_gemini_3_models() {
753        let request = Request {
754            model: Cow::Borrowed("gemini-3-flash-preview"),
755            system: None,
756            messages: Cow::Owned(vec![Message::user(ContentBlock::text("hi"))]),
757            tools: Cow::Owned(vec![]),
758            tool_choice: Some(ToolChoice::Auto),
759            temperature: None,
760            max_output_tokens: None,
761            metadata: Cow::Owned(BTreeMap::new()),
762            provider_request_options: ProviderRequestOptions {
763                reasoning: Some(ReasoningOptions {
764                    effort: Some(ReasoningEffort::High),
765                    summary: None,
766                }),
767                ..Default::default()
768            },
769        };
770
771        let payload =
772            serde_json::to_value(GeminiGenerateContentRequest::try_from(request).unwrap())
773                .expect("request should serialize");
774
775        assert_eq!(
776            payload["generationConfig"]["thinkingConfig"]["thinkingLevel"],
777            "high"
778        );
779    }
780
781    #[test]
782    fn rejects_reasoning_effort_for_gemini_2_5_models() {
783        let request = Request {
784            model: Cow::Borrowed("gemini-2.5-flash"),
785            system: None,
786            messages: Cow::Owned(vec![Message::user(ContentBlock::text("hi"))]),
787            tools: Cow::Owned(vec![]),
788            tool_choice: Some(ToolChoice::Auto),
789            temperature: None,
790            max_output_tokens: None,
791            metadata: Cow::Owned(BTreeMap::new()),
792            provider_request_options: ProviderRequestOptions {
793                reasoning: Some(ReasoningOptions {
794                    effort: Some(ReasoningEffort::Low),
795                    summary: None,
796                }),
797                ..Default::default()
798            },
799        };
800
801        let error = GeminiGenerateContentRequest::try_from(request)
802            .err()
803            .expect("request should fail");
804        match error {
805            ProviderError::InvalidRequest(message) => {
806                assert!(message.contains("Gemini 3"));
807            }
808            other => panic!("unexpected error: {other:?}"),
809        }
810    }
811
812    #[test]
813    fn rejects_hosted_tool_search_with_deferred_tools() {
814        let request = Request {
815            model: Cow::Borrowed("gemini-2.0-flash"),
816            system: None,
817            messages: Cow::Owned(vec![Message::user(ContentBlock::text("hi"))]),
818            tools: Cow::Owned(vec![ToolSpec {
819                name: "echo".to_string(),
820                description: None,
821                input_schema: json!({"type":"object"}),
822                output_schema: None,
823                kind: crate::ProviderToolKind::Function,
824                loading_policy: ToolLoadingPolicy::Deferred,
825                options: None,
826            }]),
827            tool_choice: Some(ToolChoice::Auto),
828            temperature: None,
829            max_output_tokens: None,
830            metadata: Cow::Owned(BTreeMap::new()),
831            provider_request_options: ProviderRequestOptions {
832                tool_search_mode: ToolSearchMode::Hosted,
833                ..Default::default()
834            },
835        };
836
837        let error = GeminiGenerateContentRequest::try_from(request)
838            .err()
839            .expect("request should fail");
840        match error {
841            ProviderError::InvalidRequest(message) => {
842                assert!(message.contains("does not support hosted tool search"));
843            }
844            other => panic!("unexpected error: {other:?}"),
845        }
846    }
847
848    #[test]
849    fn forced_deferred_tool_still_serializes_as_function_declaration() {
850        let request = Request {
851            model: Cow::Borrowed("gemini-2.0-flash"),
852            system: None,
853            messages: Cow::Owned(vec![Message::user(ContentBlock::text("hi"))]),
854            tools: Cow::Owned(vec![ToolSpec {
855                name: "echo".to_string(),
856                description: None,
857                input_schema: json!({"type":"object"}),
858                output_schema: None,
859                kind: crate::ProviderToolKind::Function,
860                loading_policy: ToolLoadingPolicy::Deferred,
861                options: None,
862            }]),
863            tool_choice: Some(ToolChoice::Tool {
864                name: "echo".to_string(),
865            }),
866            temperature: None,
867            max_output_tokens: None,
868            metadata: Cow::Owned(BTreeMap::new()),
869            provider_request_options: ProviderRequestOptions {
870                tool_search_mode: ToolSearchMode::Hosted,
871                ..Default::default()
872            },
873        };
874
875        let payload =
876            serde_json::to_value(GeminiGenerateContentRequest::try_from(request).unwrap())
877                .expect("request should serialize");
878
879        assert_eq!(
880            payload["tools"][0]["functionDeclarations"][0]["name"],
881            "echo"
882        );
883    }
884}