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}