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}