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