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::Thinking { .. } => Ok(GeminiPart::Text {
249                text: block
250                    .thinking_fallback_text()
251                    .expect("thinking block has fallback text"),
252            }),
253            ContentBlock::Image { source } => {
254                if !matches!(role, Role::User) {
255                    return Err(ProviderError::InvalidRequest(
256                        "Gemini image inputs are only supported in user messages".to_string(),
257                    ));
258                }
259
260                match source {
261                    ImageSource::Bytes { media_type, data } => Ok(GeminiPart::InlineData {
262                        inline_data: GeminiInlineData {
263                            mime_type: media_type.clone(),
264                            data: STANDARD.encode(data),
265                        },
266                    }),
267                    ImageSource::Url { .. } => Err(ProviderError::InvalidRequest(
268                        "Gemini image URL inputs are not supported without a file upload flow"
269                            .to_string(),
270                    )),
271                }
272            }
273            ContentBlock::ToolUse { name, input, .. } => Ok(GeminiPart::FunctionCall {
274                function_call: GeminiFunctionCall {
275                    name: name.clone(),
276                    args: input.clone(),
277                },
278            }),
279            ContentBlock::ToolResult {
280                tool_use_id,
281                content,
282                is_error,
283            } => {
284                let name = tool_name_by_id.get(tool_use_id).cloned().ok_or_else(|| {
285                    ProviderError::InvalidRequest(format!(
286                        "Gemini tool result references unknown tool_use_id '{tool_use_id}'"
287                    ))
288                })?;
289
290                Ok(GeminiPart::FunctionResponse {
291                    function_response: GeminiFunctionResponse {
292                        name,
293                        response: json!({
294                            "content": content.to_display_string(),
295                            "is_error": is_error,
296                        }),
297                    },
298                })
299            }
300            ContentBlock::HostedToolSearch { call } => Ok(GeminiPart::FunctionCall {
301                function_call: GeminiFunctionCall {
302                    name: "tool_search".to_string(),
303                    args: json!({ "query": call.query }),
304                },
305            }),
306            ContentBlock::HostedWebSearch { call } => Ok(GeminiPart::FunctionCall {
307                function_call: GeminiFunctionCall {
308                    name: "web_search".to_string(),
309                    args: serde_json::to_value(call.action.clone()).unwrap_or(Value::Null),
310                },
311            }),
312            ContentBlock::ImageGeneration { call } => Ok(GeminiPart::FunctionCall {
313                function_call: GeminiFunctionCall {
314                    name: "image_generation".to_string(),
315                    args: json!({
316                        "status": call.status,
317                        "revised_prompt": call.revised_prompt,
318                    }),
319                },
320            }),
321        }
322    }
323}
324
325#[derive(Serialize)]
326struct GeminiInlineData {
327    #[serde(rename = "mimeType")]
328    mime_type: String,
329    data: String,
330}
331
332#[derive(Serialize)]
333struct GeminiFunctionCall {
334    name: String,
335    args: Value,
336}
337
338#[derive(Serialize)]
339struct GeminiFunctionResponse {
340    name: String,
341    response: Value,
342}
343
344#[derive(Serialize)]
345struct GeminiTool {
346    #[serde(rename = "functionDeclarations")]
347    function_declarations: Vec<GeminiFunctionDeclaration>,
348}
349
350#[derive(Serialize)]
351struct GeminiFunctionDeclaration {
352    name: String,
353    #[serde(skip_serializing_if = "Option::is_none")]
354    description: Option<String>,
355    parameters: Value,
356}
357
358impl From<&ToolSpec> for GeminiFunctionDeclaration {
359    fn from(tool: &ToolSpec) -> Self {
360        GeminiFunctionDeclaration {
361            name: tool.name.clone(),
362            description: tool.description.clone(),
363            parameters: tool.input_schema.clone(),
364        }
365    }
366}
367
368#[derive(Serialize)]
369struct GeminiToolConfig {
370    #[serde(rename = "functionCallingConfig")]
371    function_calling_config: GeminiFunctionCallingConfig,
372}
373
374impl From<ToolChoice> for GeminiToolConfig {
375    fn from(choice: ToolChoice) -> Self {
376        let function_calling_config = match choice {
377            ToolChoice::Auto => GeminiFunctionCallingConfig {
378                mode: GeminiFunctionCallingMode::Auto,
379                allowed_function_names: Vec::new(),
380            },
381            ToolChoice::Any => GeminiFunctionCallingConfig {
382                mode: GeminiFunctionCallingMode::Any,
383                allowed_function_names: Vec::new(),
384            },
385            ToolChoice::Tool { name } => GeminiFunctionCallingConfig {
386                mode: GeminiFunctionCallingMode::Any,
387                allowed_function_names: vec![name],
388            },
389        };
390
391        GeminiToolConfig {
392            function_calling_config,
393        }
394    }
395}
396
397#[derive(Serialize)]
398struct GeminiFunctionCallingConfig {
399    mode: GeminiFunctionCallingMode,
400    #[serde(rename = "allowedFunctionNames", skip_serializing_if = "Vec::is_empty")]
401    allowed_function_names: Vec<String>,
402}
403
404#[derive(Serialize)]
405enum GeminiFunctionCallingMode {
406    #[serde(rename = "AUTO")]
407    Auto,
408    #[serde(rename = "ANY")]
409    Any,
410}
411
412#[derive(Serialize)]
413struct GeminiGenerationConfig {
414    #[serde(skip_serializing_if = "Option::is_none")]
415    temperature: Option<f32>,
416    #[serde(rename = "maxOutputTokens", skip_serializing_if = "Option::is_none")]
417    max_output_tokens: Option<u32>,
418    #[serde(rename = "thinkingConfig", skip_serializing_if = "Option::is_none")]
419    thinking_config: Option<GeminiThinkingConfig>,
420}
421
422impl GeminiGenerationConfig {
423    fn from_request(request: &Request<'_>) -> Result<Option<Self>, ProviderError> {
424        let thinking_config =
425            if let Some(reasoning) = request.provider_request_options.reasoning.as_ref() {
426                let Some(effort) = reasoning.effort else {
427                    return Ok(None);
428                };
429                if !supports_gemini_thinking_level(&request.model) {
430                    return Err(ProviderError::InvalidRequest(format!(
431                        "Gemini reasoning effort requires a Gemini 3 model, got '{}'",
432                        request.model
433                    )));
434                }
435
436                Some(GeminiThinkingConfig {
437                    thinking_level: effort.try_into()?,
438                })
439            } else {
440                None
441            };
442
443        let config = GeminiGenerationConfig {
444            temperature: request.temperature,
445            max_output_tokens: request.max_output_tokens,
446            thinking_config,
447        };
448
449        Ok((!config.is_empty()).then_some(config))
450    }
451
452    fn is_empty(&self) -> bool {
453        self.temperature.is_none()
454            && self.max_output_tokens.is_none()
455            && self.thinking_config.is_none()
456    }
457}
458
459#[derive(Serialize)]
460struct GeminiThinkingConfig {
461    #[serde(rename = "thinkingLevel")]
462    thinking_level: GeminiThinkingLevel,
463}
464
465#[derive(Serialize)]
466#[serde(rename_all = "snake_case")]
467enum GeminiThinkingLevel {
468    Low,
469    Medium,
470    High,
471}
472
473impl TryFrom<ReasoningEffort> for GeminiThinkingLevel {
474    type Error = ProviderError;
475
476    fn try_from(value: ReasoningEffort) -> Result<Self, Self::Error> {
477        match value {
478            ReasoningEffort::Low => Ok(Self::Low),
479            ReasoningEffort::Medium => Ok(Self::Medium),
480            ReasoningEffort::High => Ok(Self::High),
481            ReasoningEffort::XHigh => Err(ProviderError::InvalidRequest(
482                "Gemini does not support reasoning effort 'xhigh'".to_string(),
483            )),
484            ReasoningEffort::Max => Err(ProviderError::InvalidRequest(
485                "Gemini does not support reasoning effort 'max'".to_string(),
486            )),
487        }
488    }
489}
490
491fn supports_gemini_thinking_level(model: &str) -> bool {
492    let model = model.strip_prefix("models/").unwrap_or(model);
493    model.starts_with("gemini-3")
494}
495
496#[cfg(test)]
497mod tests {
498    use std::{borrow::Cow, collections::BTreeMap};
499
500    use serde_json::json;
501
502    use crate::{
503        BuiltinProvider, ContentBlock, Message, ModelInfo, ProviderError, ProviderRequestOptions,
504        ReasoningEffort, ReasoningOptions, Request, Role, ToolChoice, ToolLoadingPolicy,
505        ToolResultContent, ToolSearchMode, ToolSpec,
506    };
507
508    use super::{GeminiGenerateContentRequest, GeminiModel};
509
510    #[test]
511    fn converts_model_name_to_base_model_id() {
512        let model = GeminiModel {
513            name: "models/gemini-3-flash".to_string(),
514            base_model_id: Some("gemini-3-flash".to_string()),
515            display_name: Some("Gemini 3 Flash".to_string()),
516            description: Some("Test".to_string()),
517            supported_generation_methods: vec!["generateContent".to_string()],
518        };
519
520        let info = ModelInfo::from(model);
521
522        assert_eq!(info.id, "gemini-3-flash");
523        assert_eq!(info.provider, BuiltinProvider::Gemini.into());
524        assert_eq!(info.display_name.as_deref(), Some("Gemini 3 Flash"));
525    }
526
527    #[test]
528    fn converts_request_to_gemini_payload() {
529        let request = Request {
530            model: Cow::Borrowed("gemini-2.0-flash"),
531            system: Some(Cow::Borrowed("Be helpful.")),
532            messages: Cow::Owned(vec![
533                Message::user(ContentBlock::text("What files changed?")),
534                Message::assistant(ContentBlock::ToolUse {
535                    id: "call_1".to_string(),
536                    name: "files".to_string(),
537                    input: json!({ "operations": [{ "op": "read", "path": "README.md" }] }),
538                }),
539                Message::user(ContentBlock::ToolResult {
540                    tool_use_id: "call_1".to_string(),
541                    content: ToolResultContent::text("README contents"),
542                    is_error: false,
543                }),
544            ]),
545            tools: Cow::Owned(vec![ToolSpec {
546                name: "files".to_string(),
547                description: Some("Read and edit files".to_string()),
548                input_schema: json!({
549                    "type": "object",
550                    "properties": {
551                        "operations": { "type": "array" }
552                    }
553                }),
554                output_schema: None,
555                kind: crate::ProviderToolKind::Function,
556                loading_policy: ToolLoadingPolicy::Immediate,
557                strict: None,
558                options: None,
559            }]),
560            tool_choice: Some(ToolChoice::Tool {
561                name: "files".to_string(),
562            }),
563            temperature: Some(0.2),
564            max_output_tokens: Some(256),
565            metadata: Cow::Owned(BTreeMap::from([(
566                "agent".to_string(),
567                "mentra".to_string(),
568            )])),
569            provider_request_options: ProviderRequestOptions::default(),
570        };
571
572        let payload =
573            serde_json::to_value(GeminiGenerateContentRequest::try_from(request).unwrap())
574                .expect("request should serialize");
575
576        assert_eq!(
577            payload["systemInstruction"]["parts"][0]["text"],
578            "Be helpful."
579        );
580        assert_eq!(payload["contents"][0]["role"], "user");
581        assert_eq!(
582            payload["contents"][0]["parts"][0]["text"],
583            "What files changed?"
584        );
585        assert_eq!(
586            payload["contents"][1]["parts"][0]["functionCall"]["name"],
587            "files"
588        );
589        assert_eq!(
590            payload["contents"][2]["parts"][0]["functionResponse"]["name"],
591            "files"
592        );
593        assert_eq!(
594            payload["contents"][2]["parts"][0]["functionResponse"]["response"]["content"],
595            "README contents"
596        );
597        assert_eq!(
598            payload["tools"][0]["functionDeclarations"][0]["name"],
599            "files"
600        );
601        assert_eq!(
602            payload["toolConfig"]["functionCallingConfig"]["mode"],
603            "ANY"
604        );
605        assert_eq!(
606            payload["toolConfig"]["functionCallingConfig"]["allowedFunctionNames"][0],
607            "files"
608        );
609        let temperature = payload["generationConfig"]["temperature"]
610            .as_f64()
611            .expect("temperature should be numeric");
612        assert!((temperature - 0.2).abs() < 1e-6);
613        assert_eq!(payload["generationConfig"]["maxOutputTokens"], 256);
614        assert!(payload.get("metadata").is_none());
615    }
616
617    #[test]
618    fn serializes_inline_images_into_inline_data_parts() {
619        let request = Request {
620            model: Cow::Borrowed("gemini-2.0-flash"),
621            system: None,
622            messages: Cow::Owned(vec![Message {
623                role: Role::User,
624                content: vec![
625                    ContentBlock::text("Describe this"),
626                    ContentBlock::image_bytes("image/png", [1_u8, 2, 3]),
627                ],
628            }]),
629            tools: Cow::Owned(vec![]),
630            tool_choice: Some(ToolChoice::Auto),
631            temperature: None,
632            max_output_tokens: None,
633            metadata: Cow::Owned(BTreeMap::new()),
634            provider_request_options: ProviderRequestOptions::default(),
635        };
636
637        let payload =
638            serde_json::to_value(GeminiGenerateContentRequest::try_from(request).unwrap())
639                .expect("request should serialize");
640
641        assert_eq!(payload["contents"][0]["parts"][0]["text"], "Describe this");
642        assert_eq!(
643            payload["contents"][0]["parts"][1]["inlineData"]["mimeType"],
644            "image/png"
645        );
646        assert_eq!(
647            payload["contents"][0]["parts"][1]["inlineData"]["data"],
648            "AQID"
649        );
650    }
651
652    #[test]
653    fn rejects_url_images() {
654        let request = Request {
655            model: Cow::Borrowed("gemini-2.0-flash"),
656            system: None,
657            messages: Cow::Owned(vec![Message::user(ContentBlock::image_url(
658                "https://example.com/image.png",
659            ))]),
660            tools: Cow::Owned(vec![]),
661            tool_choice: None,
662            temperature: None,
663            max_output_tokens: None,
664            metadata: Cow::Owned(BTreeMap::new()),
665            provider_request_options: ProviderRequestOptions::default(),
666        };
667
668        let error = GeminiGenerateContentRequest::try_from(request)
669            .err()
670            .expect("request should fail");
671        match error {
672            ProviderError::InvalidRequest(message) => {
673                assert!(message.contains("image URL inputs are not supported"));
674            }
675            other => panic!("unexpected error: {other:?}"),
676        }
677    }
678
679    #[test]
680    fn serializes_tool_choice_modes() {
681        let request = Request {
682            model: Cow::Borrowed("gemini-2.0-flash"),
683            system: None,
684            messages: Cow::Owned(vec![Message::user(ContentBlock::text("hi"))]),
685            tools: Cow::Owned(vec![ToolSpec {
686                name: "echo".to_string(),
687                description: None,
688                input_schema: json!({"type":"object"}),
689                output_schema: None,
690                kind: crate::ProviderToolKind::Function,
691                loading_policy: ToolLoadingPolicy::Immediate,
692                strict: None,
693                options: None,
694            }]),
695            tool_choice: Some(ToolChoice::Any),
696            temperature: None,
697            max_output_tokens: None,
698            metadata: Cow::Owned(BTreeMap::new()),
699            provider_request_options: ProviderRequestOptions::default(),
700        };
701        let any_payload =
702            serde_json::to_value(GeminiGenerateContentRequest::try_from(request).unwrap())
703                .expect("request should serialize");
704        assert_eq!(
705            any_payload["toolConfig"]["functionCallingConfig"]["mode"],
706            "ANY"
707        );
708
709        let request = Request {
710            model: Cow::Borrowed("gemini-2.0-flash"),
711            system: None,
712            messages: Cow::Owned(vec![Message::user(ContentBlock::text("hi"))]),
713            tools: Cow::Owned(vec![ToolSpec {
714                name: "echo".to_string(),
715                description: None,
716                input_schema: json!({"type":"object"}),
717                output_schema: None,
718                kind: crate::ProviderToolKind::Function,
719                loading_policy: ToolLoadingPolicy::Immediate,
720                strict: None,
721                options: None,
722            }]),
723            tool_choice: Some(ToolChoice::Auto),
724            temperature: None,
725            max_output_tokens: None,
726            metadata: Cow::Owned(BTreeMap::new()),
727            provider_request_options: ProviderRequestOptions::default(),
728        };
729        let auto_payload =
730            serde_json::to_value(GeminiGenerateContentRequest::try_from(request).unwrap())
731                .expect("request should serialize");
732        assert_eq!(
733            auto_payload["toolConfig"]["functionCallingConfig"]["mode"],
734            "AUTO"
735        );
736    }
737
738    #[test]
739    fn omits_tool_config_when_tool_choice_is_unset() {
740        let request = Request {
741            model: Cow::Borrowed("gemini-2.0-flash"),
742            system: None,
743            messages: Cow::Owned(vec![Message::user(ContentBlock::text("hi"))]),
744            tools: Cow::Owned(vec![ToolSpec {
745                name: "echo".to_string(),
746                description: None,
747                input_schema: json!({"type":"object"}),
748                output_schema: None,
749                kind: crate::ProviderToolKind::Function,
750                loading_policy: ToolLoadingPolicy::Immediate,
751                strict: None,
752                options: None,
753            }]),
754            tool_choice: None,
755            temperature: None,
756            max_output_tokens: None,
757            metadata: Cow::Owned(BTreeMap::new()),
758            provider_request_options: ProviderRequestOptions::default(),
759        };
760
761        let payload =
762            serde_json::to_value(GeminiGenerateContentRequest::try_from(request).unwrap())
763                .expect("request should serialize");
764
765        assert!(payload.get("toolConfig").is_none());
766    }
767
768    #[test]
769    fn serializes_shared_reasoning_effort_for_gemini_3_models() {
770        for (effort, expected) in [
771            (ReasoningEffort::Low, "low"),
772            (ReasoningEffort::Medium, "medium"),
773            (ReasoningEffort::High, "high"),
774        ] {
775            let request = Request {
776                model: Cow::Borrowed("gemini-3-flash-preview"),
777                system: None,
778                messages: Cow::Owned(vec![Message::user(ContentBlock::text("hi"))]),
779                tools: Cow::Owned(vec![]),
780                tool_choice: Some(ToolChoice::Auto),
781                temperature: None,
782                max_output_tokens: None,
783                metadata: Cow::Owned(BTreeMap::new()),
784                provider_request_options: ProviderRequestOptions {
785                    reasoning: Some(ReasoningOptions {
786                        effort: Some(effort),
787                        summary: None,
788                    }),
789                    ..Default::default()
790                },
791            };
792
793            let payload =
794                serde_json::to_value(GeminiGenerateContentRequest::try_from(request).unwrap())
795                    .expect("request should serialize");
796
797            assert_eq!(
798                payload["generationConfig"]["thinkingConfig"]["thinkingLevel"],
799                expected
800            );
801        }
802    }
803
804    #[test]
805    fn rejects_reasoning_effort_for_gemini_2_5_models() {
806        let request = Request {
807            model: Cow::Borrowed("gemini-2.5-flash"),
808            system: None,
809            messages: Cow::Owned(vec![Message::user(ContentBlock::text("hi"))]),
810            tools: Cow::Owned(vec![]),
811            tool_choice: Some(ToolChoice::Auto),
812            temperature: None,
813            max_output_tokens: None,
814            metadata: Cow::Owned(BTreeMap::new()),
815            provider_request_options: ProviderRequestOptions {
816                reasoning: Some(ReasoningOptions {
817                    effort: Some(ReasoningEffort::Low),
818                    summary: None,
819                }),
820                ..Default::default()
821            },
822        };
823
824        let error = GeminiGenerateContentRequest::try_from(request)
825            .err()
826            .expect("request should fail");
827        match error {
828            ProviderError::InvalidRequest(message) => {
829                assert!(message.contains("Gemini 3"));
830            }
831            other => panic!("unexpected error: {other:?}"),
832        }
833    }
834
835    #[test]
836    fn rejects_extended_reasoning_effort_for_gemini() {
837        for (effort, expected) in [
838            (ReasoningEffort::XHigh, "xhigh"),
839            (ReasoningEffort::Max, "max"),
840        ] {
841            let request = Request {
842                model: Cow::Borrowed("gemini-3-flash-preview"),
843                system: None,
844                messages: Cow::Owned(vec![Message::user(ContentBlock::text("hi"))]),
845                tools: Cow::Owned(vec![]),
846                tool_choice: Some(ToolChoice::Auto),
847                temperature: None,
848                max_output_tokens: None,
849                metadata: Cow::Owned(BTreeMap::new()),
850                provider_request_options: ProviderRequestOptions {
851                    reasoning: Some(ReasoningOptions {
852                        effort: Some(effort),
853                        summary: None,
854                    }),
855                    ..Default::default()
856                },
857            };
858
859            let error = GeminiGenerateContentRequest::try_from(request)
860                .err()
861                .expect("extended Gemini effort should fail");
862            match error {
863                ProviderError::InvalidRequest(message) => {
864                    assert!(message.contains(expected));
865                }
866                other => panic!("unexpected error: {other:?}"),
867            }
868        }
869    }
870
871    #[test]
872    fn rejects_hosted_tool_search_with_deferred_tools() {
873        let request = Request {
874            model: Cow::Borrowed("gemini-2.0-flash"),
875            system: None,
876            messages: Cow::Owned(vec![Message::user(ContentBlock::text("hi"))]),
877            tools: Cow::Owned(vec![ToolSpec {
878                name: "echo".to_string(),
879                description: None,
880                input_schema: json!({"type":"object"}),
881                output_schema: None,
882                kind: crate::ProviderToolKind::Function,
883                loading_policy: ToolLoadingPolicy::Deferred,
884                strict: None,
885                options: None,
886            }]),
887            tool_choice: Some(ToolChoice::Auto),
888            temperature: None,
889            max_output_tokens: None,
890            metadata: Cow::Owned(BTreeMap::new()),
891            provider_request_options: ProviderRequestOptions {
892                tool_search_mode: ToolSearchMode::Hosted,
893                ..Default::default()
894            },
895        };
896
897        let error = GeminiGenerateContentRequest::try_from(request)
898            .err()
899            .expect("request should fail");
900        match error {
901            ProviderError::InvalidRequest(message) => {
902                assert!(message.contains("does not support hosted tool search"));
903            }
904            other => panic!("unexpected error: {other:?}"),
905        }
906    }
907
908    #[test]
909    fn forced_deferred_tool_still_serializes_as_function_declaration() {
910        let request = Request {
911            model: Cow::Borrowed("gemini-2.0-flash"),
912            system: None,
913            messages: Cow::Owned(vec![Message::user(ContentBlock::text("hi"))]),
914            tools: Cow::Owned(vec![ToolSpec {
915                name: "echo".to_string(),
916                description: None,
917                input_schema: json!({"type":"object"}),
918                output_schema: None,
919                kind: crate::ProviderToolKind::Function,
920                loading_policy: ToolLoadingPolicy::Deferred,
921                strict: None,
922                options: None,
923            }]),
924            tool_choice: Some(ToolChoice::Tool {
925                name: "echo".to_string(),
926            }),
927            temperature: None,
928            max_output_tokens: None,
929            metadata: Cow::Owned(BTreeMap::new()),
930            provider_request_options: ProviderRequestOptions {
931                tool_search_mode: ToolSearchMode::Hosted,
932                ..Default::default()
933            },
934        };
935
936        let payload =
937            serde_json::to_value(GeminiGenerateContentRequest::try_from(request).unwrap())
938                .expect("request should serialize");
939
940        assert_eq!(
941            payload["tools"][0]["functionDeclarations"][0]["name"],
942            "echo"
943        );
944    }
945}