1use crate::protocol::{FromProvider, ProtocolError, ProtocolResult, ToProvider};
26use bamboo_domain::{FunctionCall, ToolCall};
27use bamboo_domain::{FunctionSchema, ToolSchema};
28use bamboo_domain::{Message, Role};
29use serde::{Deserialize, Serialize};
30use serde_json::Value;
31
32pub struct GeminiProtocol;
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct GeminiRequest {
42 pub contents: Vec<GeminiContent>,
44 #[serde(
46 skip_serializing_if = "Option::is_none",
47 rename = "systemInstruction",
48 alias = "system_instruction"
49 )]
50 pub system_instruction: Option<GeminiContent>,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub tools: Option<Vec<GeminiTool>>,
54 #[serde(
56 skip_serializing_if = "Option::is_none",
57 rename = "generationConfig",
58 alias = "generation_config"
59 )]
60 pub generation_config: Option<Value>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct GeminiContent {
66 pub role: String,
68 pub parts: Vec<GeminiPart>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct GeminiPart {
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub text: Option<String>,
78 #[serde(
80 skip_serializing_if = "Option::is_none",
81 rename = "inlineData",
82 alias = "inline_data"
83 )]
84 pub inline_data: Option<GeminiInlineData>,
85 #[serde(
87 skip_serializing_if = "Option::is_none",
88 rename = "fileData",
89 alias = "file_data"
90 )]
91 pub file_data: Option<GeminiFileData>,
92 #[serde(
94 skip_serializing_if = "Option::is_none",
95 rename = "functionCall",
96 alias = "function_call"
97 )]
98 pub function_call: Option<GeminiFunctionCall>,
99 #[serde(
101 skip_serializing_if = "Option::is_none",
102 rename = "functionResponse",
103 alias = "function_response"
104 )]
105 pub function_response: Option<GeminiFunctionResponse>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct GeminiInlineData {
111 #[serde(rename = "mimeType", alias = "mime_type")]
112 pub mime_type: String,
113 pub data: String,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct GeminiFileData {
119 #[serde(rename = "fileUri", alias = "file_uri")]
120 pub file_uri: String,
121 #[serde(
122 skip_serializing_if = "Option::is_none",
123 rename = "mimeType",
124 alias = "mime_type"
125 )]
126 pub mime_type: Option<String>,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct GeminiFunctionCall {
132 pub name: String,
133 pub args: Value,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct GeminiFunctionResponse {
139 pub name: String,
140 pub response: Value,
141}
142
143fn normalize_function_response(content: &str) -> Value {
144 match serde_json::from_str(content) {
145 Ok(Value::Object(response)) => Value::Object(response),
146 Ok(response) => serde_json::json!({ "result": response }),
147 Err(_) => serde_json::json!({ "result": content }),
148 }
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct GeminiTool {
154 #[serde(rename = "functionDeclarations", alias = "function_declarations")]
155 pub function_declarations: Vec<GeminiFunctionDeclaration>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct GeminiFunctionDeclaration {
161 pub name: String,
162 #[serde(skip_serializing_if = "Option::is_none")]
163 pub description: Option<String>,
164 #[serde(
169 skip_serializing_if = "Option::is_none",
170 rename = "parametersJsonSchema",
171 alias = "parameters_json_schema"
172 )]
173 pub parameters_json_schema: Option<Value>,
174 #[serde(
178 skip_serializing_if = "Option::is_none",
179 rename = "parameters",
180 alias = "parameters"
181 )]
182 pub parameters: Option<Value>,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct GeminiResponse {
188 pub candidates: Vec<GeminiCandidate>,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct GeminiCandidate {
194 pub content: GeminiContent,
195 #[serde(skip_serializing_if = "Option::is_none")]
196 pub finish_reason: Option<String>,
197}
198
199impl FromProvider<GeminiContent> for Message {
204 fn from_provider(content: GeminiContent) -> ProtocolResult<Self> {
205 let role = match content.role.as_str() {
206 "user" => Role::User,
207 "model" => Role::Assistant,
208 "system" => Role::System,
209 _ => return Err(ProtocolError::InvalidRole(content.role)),
210 };
211
212 let mut text_parts = Vec::new();
214 let mut content_parts = Vec::new();
215 let mut tool_calls = Vec::new();
216 let mut has_image_parts = false;
217
218 for part in content.parts {
219 if let Some(text) = part.text {
220 text_parts.push(text.clone());
221 content_parts.push(bamboo_domain::MessagePart::Text { text });
222 }
223
224 if let Some(inline_data) = part.inline_data {
225 if let Some(url) = inline_data_to_data_url(&inline_data) {
226 has_image_parts = true;
227 content_parts.push(bamboo_domain::MessagePart::ImageUrl {
228 image_url: bamboo_domain::ImageUrlRef { url, detail: None },
229 });
230 }
231 }
232
233 if let Some(file_data) = part.file_data {
234 let file_uri = file_data.file_uri.trim();
235 if !file_uri.is_empty() {
236 has_image_parts = true;
237 content_parts.push(bamboo_domain::MessagePart::ImageUrl {
238 image_url: bamboo_domain::ImageUrlRef {
239 url: file_uri.to_string(),
240 detail: None,
241 },
242 });
243 }
244 }
245
246 if let Some(func_call) = part.function_call {
247 tool_calls.push(ToolCall {
248 id: format!("gemini_{}", uuid::Uuid::new_v4()), tool_type: "function".to_string(),
250 function: FunctionCall {
251 name: func_call.name,
252 arguments: serde_json::to_string(&func_call.args).unwrap_or_default(),
253 },
254 });
255 }
256
257 if let Some(func_response) = part.function_response {
258 return Ok(Message::tool_result(
260 format!("gemini_tool_{}", func_response.name),
261 serde_json::to_string(&func_response.response).unwrap_or_default(),
262 ));
263 }
264 }
265
266 let content_text = text_parts.join("");
267
268 Ok(Message {
269 id: String::new(),
270 role,
271 content: content_text,
272 reasoning: None,
273 reasoning_signature: None,
274 content_parts: has_image_parts.then_some(content_parts),
275 image_ocr: None,
276 phase: None,
277 tool_calls: if tool_calls.is_empty() {
278 None
279 } else {
280 Some(tool_calls)
281 },
282 tool_call_id: None,
283 tool_success: None,
284 compressed: false,
285 compressed_by_event_id: None,
286 never_compress: false,
287 compression_level: 0,
288 created_at: chrono::Utc::now(),
289 metadata: None,
290 })
291 }
292}
293
294impl FromProvider<GeminiTool> for ToolSchema {
295 fn from_provider(tool: GeminiTool) -> ProtocolResult<Self> {
296 let func = tool
299 .function_declarations
300 .into_iter()
301 .next()
302 .ok_or_else(|| ProtocolError::InvalidToolCall("Empty tool declarations".to_string()))?;
303
304 let parameters = func
305 .parameters_json_schema
306 .or(func.parameters)
307 .unwrap_or(Value::Null);
308
309 Ok(ToolSchema {
310 schema_type: "function".to_string(),
311 function: FunctionSchema {
312 name: func.name,
313 description: func.description.unwrap_or_default(),
314 parameters,
315 },
316 })
317 }
318}
319
320pub struct GeminiRequestBuilder;
328
329impl ToProvider<GeminiRequest> for Vec<Message> {
330 fn to_provider(&self) -> ProtocolResult<GeminiRequest> {
331 crate::image_budget::validate_tool_image_budget(self)
332 .map_err(ProtocolError::InvalidContent)?;
333 let mut system_texts = Vec::new();
334 let mut contents = Vec::new();
335
336 for msg in self {
337 match msg.role {
338 Role::System => {
339 let trimmed = msg.content.trim();
340 if !trimmed.is_empty() {
341 system_texts.push(trimmed.to_string());
342 }
343 }
344 _ => {
345 contents.push(msg.to_provider()?);
346 }
347 }
348 }
349
350 let system_instruction = if system_texts.is_empty() {
351 None
352 } else {
353 Some(GeminiContent {
354 role: "system".to_string(),
355 parts: vec![GeminiPart {
356 text: Some(system_texts.join("\n\n")),
357 inline_data: None,
358 file_data: None,
359 function_call: None,
360 function_response: None,
361 }],
362 })
363 };
364
365 Ok(GeminiRequest {
366 contents,
367 system_instruction,
368 tools: None,
369 generation_config: None,
370 })
371 }
372}
373
374impl ToProvider<GeminiContent> for Message {
375 fn to_provider(&self) -> ProtocolResult<GeminiContent> {
376 if self.role == Role::Tool {
378 let tool_name = self
379 .tool_call_id
380 .clone()
381 .ok_or_else(|| ProtocolError::MissingField("tool_call_id".to_string()))?;
382
383 let mut parts = vec![GeminiPart {
384 text: None,
385 inline_data: None,
386 file_data: None,
387 function_call: None,
388 function_response: Some(GeminiFunctionResponse {
389 name: tool_name,
390 response: normalize_function_response(&self.content),
394 }),
395 }];
396 if let Some(content_parts) = self.content_parts.as_ref() {
400 parts.extend(content_parts.iter().filter_map(|part| match part {
401 bamboo_domain::MessagePart::ImageUrl { .. } => {
402 message_content_part_to_gemini_part(part)
403 }
404 _ => None,
405 }));
406 }
407 return Ok(GeminiContent {
408 role: "user".to_string(),
409 parts,
410 });
411 }
412
413 let role = match self.role {
414 Role::User => "user",
415 Role::Assistant => "model",
416 Role::System => "system",
417 Role::Tool => "user", };
419
420 let mut parts = Vec::new();
421
422 if let Some(content_parts) = self.content_parts.as_ref() {
424 for part in content_parts {
425 if let Some(gemini_part) = message_content_part_to_gemini_part(part) {
426 parts.push(gemini_part);
427 }
428 }
429 }
430
431 if parts.is_empty() && !self.content.is_empty() {
433 parts.push(GeminiPart {
434 text: Some(self.content.clone()),
435 inline_data: None,
436 file_data: None,
437 function_call: None,
438 function_response: None,
439 });
440 }
441
442 if let Some(tool_calls) = &self.tool_calls {
444 for tc in tool_calls {
445 let args: Value = serde_json::from_str(&tc.function.arguments)
446 .unwrap_or_else(|_| Value::Object(serde_json::Map::new()));
447
448 parts.push(GeminiPart {
449 text: None,
450 inline_data: None,
451 file_data: None,
452 function_call: Some(GeminiFunctionCall {
453 name: tc.function.name.clone(),
454 args,
455 }),
456 function_response: None,
457 });
458 }
459 }
460
461 if parts.is_empty() {
463 parts.push(GeminiPart {
464 text: Some(String::new()),
465 inline_data: None,
466 file_data: None,
467 function_call: None,
468 function_response: None,
469 });
470 }
471
472 Ok(GeminiContent {
473 role: role.to_string(),
474 parts,
475 })
476 }
477}
478
479impl ToProvider<GeminiTool> for ToolSchema {
480 fn to_provider(&self) -> ProtocolResult<GeminiTool> {
481 Ok(GeminiTool {
482 function_declarations: vec![GeminiFunctionDeclaration {
483 name: self.function.name.clone(),
484 description: Some(self.function.description.clone()),
485 parameters_json_schema: Some(
486 crate::providers::common::tool_schema::canonicalize_json_value(
487 &self.function.parameters,
488 ),
489 ),
490 parameters: None,
491 }],
492 })
493 }
494}
495
496impl ToProvider<Vec<GeminiTool>> for Vec<ToolSchema> {
501 fn to_provider(&self) -> ProtocolResult<Vec<GeminiTool>> {
502 let declarations: Vec<GeminiFunctionDeclaration> = self
504 .iter()
505 .map(|schema| GeminiFunctionDeclaration {
506 name: schema.function.name.clone(),
507 description: Some(schema.function.description.clone()),
508 parameters_json_schema: Some(
509 crate::providers::common::tool_schema::canonicalize_json_value(
510 &schema.function.parameters,
511 ),
512 ),
513 parameters: None,
514 })
515 .collect();
516
517 if declarations.is_empty() {
518 Ok(vec![])
519 } else {
520 Ok(vec![GeminiTool {
521 function_declarations: declarations,
522 }])
523 }
524 }
525}
526
527fn message_content_part_to_gemini_part(part: &bamboo_domain::MessagePart) -> Option<GeminiPart> {
528 match part {
529 bamboo_domain::MessagePart::Text { text } => Some(GeminiPart {
530 text: Some(text.clone()),
531 inline_data: None,
532 file_data: None,
533 function_call: None,
534 function_response: None,
535 }),
536 bamboo_domain::MessagePart::ImageUrl { image_url } => {
537 image_url_to_gemini_part(&image_url.url)
538 }
539 }
540}
541
542fn image_url_to_gemini_part(url: &str) -> Option<GeminiPart> {
543 let trimmed = url.trim();
544 if trimmed.is_empty() {
545 return None;
546 }
547
548 if let Some((mime_type, data)) = parse_data_url_base64(trimmed) {
549 return Some(GeminiPart {
550 text: None,
551 inline_data: Some(GeminiInlineData { mime_type, data }),
552 file_data: None,
553 function_call: None,
554 function_response: None,
555 });
556 }
557
558 Some(GeminiPart {
559 text: None,
560 inline_data: None,
561 file_data: Some(GeminiFileData {
562 file_uri: trimmed.to_string(),
563 mime_type: None,
564 }),
565 function_call: None,
566 function_response: None,
567 })
568}
569
570fn parse_data_url_base64(url: &str) -> Option<(String, String)> {
571 let rest = url.strip_prefix("data:")?;
572 let (meta, data) = rest.split_once(',')?;
573 let data = data.trim();
574 if data.is_empty() {
575 return None;
576 }
577
578 let mut mime_type = "application/octet-stream";
579 let mut is_base64 = false;
580 for (idx, seg) in meta.split(';').enumerate() {
581 let segment = seg.trim();
582 if idx == 0 && !segment.is_empty() && !segment.eq_ignore_ascii_case("base64") {
583 mime_type = segment;
584 }
585 if segment.eq_ignore_ascii_case("base64") {
586 is_base64 = true;
587 }
588 }
589
590 if !is_base64 {
591 return None;
592 }
593
594 Some((mime_type.to_string(), data.to_string()))
595}
596
597fn inline_data_to_data_url(inline: &GeminiInlineData) -> Option<String> {
598 let mime_type = inline.mime_type.trim();
599 let data = inline.data.trim();
600 if mime_type.is_empty() || data.is_empty() {
601 return None;
602 }
603 Some(format!("data:{mime_type};base64,{data}"))
604}
605
606pub trait GeminiExt: Sized {
612 fn into_internal(self) -> ProtocolResult<Message>;
613 fn to_gemini(&self) -> ProtocolResult<GeminiContent>;
614}
615
616impl GeminiExt for GeminiContent {
617 fn into_internal(self) -> ProtocolResult<Message> {
618 Message::from_provider(self)
619 }
620
621 fn to_gemini(&self) -> ProtocolResult<GeminiContent> {
622 Ok(self.clone())
623 }
624}
625
626impl GeminiExt for Message {
627 fn into_internal(self) -> ProtocolResult<Message> {
628 Ok(self)
629 }
630
631 fn to_gemini(&self) -> ProtocolResult<GeminiContent> {
632 self.to_provider()
633 }
634}
635
636#[cfg(test)]
641mod tests {
642 use super::*;
643 use crate::models::{ContentPart, ImageUrl};
644 use bamboo_domain::MessagePart;
645
646 #[test]
647 fn test_gemini_to_internal_user_message() {
648 let gemini = GeminiContent {
649 role: "user".to_string(),
650 parts: vec![GeminiPart {
651 text: Some("Hello".to_string()),
652 inline_data: None,
653 file_data: None,
654 function_call: None,
655 function_response: None,
656 }],
657 };
658
659 let internal: Message = Message::from_provider(gemini).unwrap();
660
661 assert_eq!(internal.role, Role::User);
662 assert_eq!(internal.content, "Hello");
663 assert!(internal.tool_calls.is_none());
664 }
665
666 #[test]
667 fn test_internal_to_gemini_user_message() {
668 let internal = Message::user("Hello");
669
670 let gemini: GeminiContent = internal.to_provider().unwrap();
671
672 assert_eq!(gemini.role, "user");
673 assert_eq!(gemini.parts.len(), 1);
674 assert_eq!(gemini.parts[0].text, Some("Hello".to_string()));
675 }
676
677 #[test]
678 fn vision_tool_images_reach_native_gemini_request() {
679 let messages = vec![Message::tool_result_with_images(
680 "view-image",
681 "loaded two images",
682 true,
683 vec![
684 bamboo_domain::ToolResultImage {
685 mime_type: "image/png".into(),
686 data: "FIRST".into(),
687 },
688 bamboo_domain::ToolResultImage {
689 mime_type: "image/jpeg".into(),
690 data: "SECOND".into(),
691 },
692 ],
693 )];
694 let request: GeminiRequest = messages.to_provider().unwrap();
695 let wire = serde_json::to_value(request).unwrap();
696 let parts = wire["contents"][0]["parts"].as_array().unwrap();
697 assert_eq!(parts.len(), 3);
698 assert_eq!(parts[0]["functionResponse"]["name"], "view-image");
699 assert_eq!(parts[1]["inlineData"]["mimeType"], "image/png");
700 assert_eq!(parts[1]["inlineData"]["data"], "FIRST");
701 assert_eq!(parts[2]["inlineData"]["data"], "SECOND");
702 }
703
704 #[test]
705 fn test_internal_to_gemini_with_data_url_image_part() {
706 let internal = Message::user_with_parts(
707 "describe",
708 vec![
709 ContentPart::Text {
710 text: "describe".to_string(),
711 },
712 ContentPart::ImageUrl {
713 image_url: ImageUrl {
714 url: "data:image/png;base64,AAAA".to_string(),
715 detail: None,
716 },
717 },
718 ]
719 .into_iter()
720 .map(Into::into)
721 .collect(),
722 );
723
724 let gemini: GeminiContent = internal.to_provider().unwrap();
725
726 assert_eq!(gemini.parts.len(), 2);
727 assert_eq!(gemini.parts[0].text, Some("describe".to_string()));
728 let inline = gemini.parts[1]
729 .inline_data
730 .as_ref()
731 .expect("inlineData should be present");
732 assert_eq!(inline.mime_type, "image/png");
733 assert_eq!(inline.data, "AAAA");
734 assert!(gemini.parts[1].file_data.is_none());
735 }
736
737 #[test]
738 fn test_gemini_to_internal_model_message() {
739 let gemini = GeminiContent {
740 role: "model".to_string(),
741 parts: vec![GeminiPart {
742 text: Some("Hello there!".to_string()),
743 inline_data: None,
744 file_data: None,
745 function_call: None,
746 function_response: None,
747 }],
748 };
749
750 let internal: Message = Message::from_provider(gemini).unwrap();
751
752 assert_eq!(internal.role, Role::Assistant);
753 assert_eq!(internal.content, "Hello there!");
754 }
755
756 #[test]
757 fn test_gemini_to_internal_with_inline_data_image() {
758 let gemini = GeminiContent {
759 role: "user".to_string(),
760 parts: vec![GeminiPart {
761 text: Some("look".to_string()),
762 inline_data: Some(GeminiInlineData {
763 mime_type: "image/png".to_string(),
764 data: "BBBB".to_string(),
765 }),
766 file_data: None,
767 function_call: None,
768 function_response: None,
769 }],
770 };
771
772 let internal: Message = Message::from_provider(gemini).unwrap();
773 assert_eq!(internal.content, "look");
774 let parts = internal
775 .content_parts
776 .as_ref()
777 .expect("content_parts should preserve image");
778 assert!(parts.iter().any(|part| {
779 matches!(
780 part,
781 MessagePart::ImageUrl { image_url }
782 if image_url.url == "data:image/png;base64,BBBB"
783 )
784 }));
785 }
786
787 #[test]
788 fn test_internal_to_gemini_with_tool_call() {
789 let tool_call = ToolCall {
790 id: "call_1".to_string(),
791 tool_type: "function".to_string(),
792 function: FunctionCall {
793 name: "search".to_string(),
794 arguments: r#"{"q":"test"}"#.to_string(),
795 },
796 };
797
798 let internal = Message::assistant("Let me search", Some(vec![tool_call]));
799
800 let gemini: GeminiContent = internal.to_provider().unwrap();
801
802 assert_eq!(gemini.role, "model");
803 assert_eq!(gemini.parts.len(), 2);
804 assert_eq!(gemini.parts[0].text, Some("Let me search".to_string()));
805 assert!(gemini.parts[1].function_call.is_some());
806
807 let func_call = gemini.parts[1].function_call.as_ref().unwrap();
808 assert_eq!(func_call.name, "search");
809 assert_eq!(func_call.args, serde_json::json!({"q": "test"}));
810 }
811
812 #[test]
813 fn test_gemini_to_internal_with_tool_call() {
814 let gemini = GeminiContent {
815 role: "model".to_string(),
816 parts: vec![GeminiPart {
817 text: None,
818 inline_data: None,
819 file_data: None,
820 function_call: Some(GeminiFunctionCall {
821 name: "search".to_string(),
822 args: serde_json::json!({"q": "test"}),
823 }),
824 function_response: None,
825 }],
826 };
827
828 let internal: Message = Message::from_provider(gemini).unwrap();
829
830 assert_eq!(internal.role, Role::Assistant);
831 assert!(internal.tool_calls.is_some());
832
833 let tool_calls = internal.tool_calls.unwrap();
834 assert_eq!(tool_calls.len(), 1);
835 assert_eq!(tool_calls[0].function.name, "search");
836 }
837
838 #[test]
839 fn test_system_message_extraction() {
840 let messages = vec![Message::system("You are helpful"), Message::user("Hello")];
841
842 let request: GeminiRequest = messages.to_provider().unwrap();
843
844 assert!(request.system_instruction.is_some());
845 let sys = request.system_instruction.unwrap();
846 assert_eq!(sys.role, "system");
847 assert_eq!(sys.parts[0].text, Some("You are helpful".to_string()));
848
849 assert_eq!(request.contents.len(), 1);
850 assert_eq!(request.contents[0].role, "user");
851 }
852
853 #[test]
854 fn test_multiple_system_messages_are_joined() {
855 let messages = vec![
856 Message::system("You are helpful"),
857 Message::system("Use tools when needed"),
858 Message::user("Hello"),
859 ];
860
861 let request: GeminiRequest = messages.to_provider().unwrap();
862
863 let sys = request
864 .system_instruction
865 .expect("system instruction should be present");
866 assert_eq!(sys.role, "system");
867 assert_eq!(
868 sys.parts[0].text.as_deref(),
869 Some("You are helpful\n\nUse tools when needed")
870 );
871 assert_eq!(request.contents.len(), 1);
872 assert_eq!(request.contents[0].role, "user");
873 }
874
875 #[test]
876 fn test_tool_response_conversion() {
877 let internal = Message::tool_result("search_tool", r#"{"result": "ok"}"#);
878
879 let gemini: GeminiContent = internal.to_provider().unwrap();
880
881 assert_eq!(gemini.role, "user");
882 assert!(gemini.parts[0].function_response.is_some());
883
884 let func_resp = gemini.parts[0].function_response.as_ref().unwrap();
885 assert_eq!(func_resp.name, "search_tool");
886 assert_eq!(func_resp.response, serde_json::json!({ "result": "ok" }));
887 }
888
889 #[test]
890 fn test_plain_text_tool_response_is_wrapped_in_object() {
891 let internal = Message::tool_result("read_file", "plain text output");
892
893 let gemini: GeminiContent = internal.to_provider().unwrap();
894
895 let func_resp = gemini.parts[0].function_response.as_ref().unwrap();
896 assert_eq!(
897 func_resp.response,
898 serde_json::json!({ "result": "plain text output" })
899 );
900 }
901
902 #[test]
903 fn test_non_object_json_tool_response_is_wrapped_in_object() {
904 let internal = Message::tool_result("list_items", r#"["first", "second"]"#);
905
906 let gemini: GeminiContent = internal.to_provider().unwrap();
907
908 let func_resp = gemini.parts[0].function_response.as_ref().unwrap();
909 assert_eq!(
910 func_resp.response,
911 serde_json::json!({ "result": ["first", "second"] })
912 );
913 }
914
915 #[test]
916 fn test_tool_schema_conversion() {
917 let gemini_tool = GeminiTool {
918 function_declarations: vec![GeminiFunctionDeclaration {
919 name: "search".to_string(),
920 description: Some("Search the web".to_string()),
921 parameters_json_schema: Some(serde_json::json!({
922 "type": "object",
923 "properties": {
924 "q": { "type": "string" }
925 }
926 })),
927 parameters: None,
928 }],
929 };
930
931 let internal_schema: ToolSchema = ToolSchema::from_provider(gemini_tool.clone()).unwrap();
933 assert_eq!(internal_schema.function.name, "search");
934
935 let roundtrip: GeminiTool = internal_schema.to_provider().unwrap();
937 assert_eq!(roundtrip.function_declarations.len(), 1);
938 assert_eq!(roundtrip.function_declarations[0].name, "search");
939 }
940
941 #[test]
942 fn test_multiple_tools_grouped() {
943 let tools = vec![
944 ToolSchema {
945 schema_type: "function".to_string(),
946 function: FunctionSchema {
947 name: "search".to_string(),
948 description: "Search".to_string(),
949 parameters: serde_json::json!({"type": "object"}),
950 },
951 },
952 ToolSchema {
953 schema_type: "function".to_string(),
954 function: FunctionSchema {
955 name: "read".to_string(),
956 description: "Read file".to_string(),
957 parameters: serde_json::json!({"type": "object"}),
958 },
959 },
960 ];
961
962 let gemini_tools: Vec<GeminiTool> = tools.to_provider().unwrap();
963
964 assert_eq!(gemini_tools.len(), 1);
966 assert_eq!(gemini_tools[0].function_declarations.len(), 2);
967 assert_eq!(gemini_tools[0].function_declarations[0].name, "search");
968 assert_eq!(gemini_tools[0].function_declarations[1].name, "read");
969 }
970
971 #[test]
972 fn test_roundtrip_conversion() {
973 let original = Message::user("Hello, world!");
974
975 let gemini: GeminiContent = original.to_provider().unwrap();
977
978 let roundtrip: Message = Message::from_provider(gemini).unwrap();
980
981 assert_eq!(roundtrip.role, original.role);
982 assert_eq!(roundtrip.content, original.content);
983 }
984
985 #[test]
986 fn test_invalid_role_error() {
987 let gemini = GeminiContent {
988 role: "invalid_role".to_string(),
989 parts: vec![GeminiPart {
990 text: Some("test".to_string()),
991 inline_data: None,
992 file_data: None,
993 function_call: None,
994 function_response: None,
995 }],
996 };
997
998 let result: ProtocolResult<Message> = Message::from_provider(gemini);
999 assert!(matches!(result, Err(ProtocolError::InvalidRole(_))));
1000 }
1001
1002 #[test]
1003 fn test_to_provider_uses_parameters_json_schema_field() {
1004 let tool = ToolSchema {
1005 schema_type: "function".to_string(),
1006 function: FunctionSchema {
1007 name: "bash".to_string(),
1008 description: "Run a command".to_string(),
1009 parameters: serde_json::json!({
1010 "type": "object",
1011 "properties": {
1012 "command": { "type": "string" }
1013 },
1014 "required": ["command"],
1015 "additionalProperties": false
1016 }),
1017 },
1018 };
1019
1020 let gemini_tool: GeminiTool = tool.to_provider().unwrap();
1021 let decl = &gemini_tool.function_declarations[0];
1022
1023 assert!(
1025 decl.parameters_json_schema.is_some(),
1026 "parameters_json_schema should be set"
1027 );
1028 assert!(
1029 decl.parameters.is_none(),
1030 "legacy parameters field should be None"
1031 );
1032
1033 let schema = decl.parameters_json_schema.as_ref().unwrap();
1035 assert_eq!(schema["additionalProperties"], false);
1036 assert_eq!(schema["properties"]["command"]["type"], "string");
1037 }
1038
1039 #[test]
1040 fn test_to_provider_serializes_as_parameters_json_schema() {
1041 let tool = ToolSchema {
1042 schema_type: "function".to_string(),
1043 function: FunctionSchema {
1044 name: "read".to_string(),
1045 description: "Read a file".to_string(),
1046 parameters: serde_json::json!({
1047 "type": "object",
1048 "properties": {
1049 "path": { "type": "string" }
1050 },
1051 "additionalProperties": false
1052 }),
1053 },
1054 };
1055
1056 let gemini_tool: GeminiTool = tool.to_provider().unwrap();
1057 let json = serde_json::to_string(&gemini_tool).unwrap();
1058
1059 assert!(
1060 json.contains("parametersJsonSchema"),
1061 "serialized JSON should use 'parametersJsonSchema', got: {json}"
1062 );
1063 assert!(
1064 !json.contains("\"parameters\":"),
1065 "legacy 'parameters' field should not appear in output, got: {json}"
1066 );
1067 assert!(
1068 json.contains("additionalProperties"),
1069 "additionalProperties should be preserved in parametersJsonSchema, got: {json}"
1070 );
1071 }
1072
1073 #[test]
1074 fn test_batch_to_provider_uses_parameters_json_schema() {
1075 let tools = vec![
1076 ToolSchema {
1077 schema_type: "function".to_string(),
1078 function: FunctionSchema {
1079 name: "bash".to_string(),
1080 description: "Run".to_string(),
1081 parameters: serde_json::json!({
1082 "type": "object",
1083 "properties": { "command": { "type": "string" } },
1084 "additionalProperties": false
1085 }),
1086 },
1087 },
1088 ToolSchema {
1089 schema_type: "function".to_string(),
1090 function: FunctionSchema {
1091 name: "read".to_string(),
1092 description: "Read".to_string(),
1093 parameters: serde_json::json!({
1094 "type": "object",
1095 "properties": {
1096 "path": { "type": "string" },
1097 "options": {
1098 "type": "object",
1099 "properties": {
1100 "encoding": { "type": "string" }
1101 },
1102 "additionalProperties": false
1103 }
1104 },
1105 "additionalProperties": false
1106 }),
1107 },
1108 },
1109 ];
1110
1111 let gemini_tools: Vec<GeminiTool> = tools.to_provider().unwrap();
1112 let serialized = serde_json::to_string(&gemini_tools).unwrap();
1113
1114 assert!(
1115 serialized.contains("parametersJsonSchema"),
1116 "should use parametersJsonSchema, got: {serialized}"
1117 );
1118 assert!(
1119 serialized.contains("additionalProperties"),
1120 "additionalProperties should be preserved, got: {serialized}"
1121 );
1122 }
1123
1124 #[test]
1125 fn test_from_provider_prefers_parameters_json_schema_over_parameters() {
1126 let tool_with_both = GeminiTool {
1127 function_declarations: vec![GeminiFunctionDeclaration {
1128 name: "search".to_string(),
1129 description: Some("Search".to_string()),
1130 parameters_json_schema: Some(serde_json::json!({
1131 "type": "object",
1132 "properties": { "q": { "type": "string" } }
1133 })),
1134 parameters: Some(serde_json::json!({
1135 "type": "object",
1136 "properties": { "query": { "type": "string" } }
1137 })),
1138 }],
1139 };
1140
1141 let schema: ToolSchema = ToolSchema::from_provider(tool_with_both).unwrap();
1142 assert_eq!(
1144 schema.function.parameters["properties"]["q"]["type"],
1145 "string"
1146 );
1147 }
1148
1149 #[test]
1150 fn test_from_provider_falls_back_to_legacy_parameters() {
1151 let legacy_tool = GeminiTool {
1152 function_declarations: vec![GeminiFunctionDeclaration {
1153 name: "legacy".to_string(),
1154 description: Some("Legacy tool".to_string()),
1155 parameters_json_schema: None,
1156 parameters: Some(serde_json::json!({
1157 "type": "object",
1158 "properties": { "x": { "type": "integer" } }
1159 })),
1160 }],
1161 };
1162
1163 let schema: ToolSchema = ToolSchema::from_provider(legacy_tool).unwrap();
1164 assert_eq!(
1165 schema.function.parameters["properties"]["x"]["type"],
1166 "integer"
1167 );
1168 }
1169
1170 #[test]
1171 fn test_from_provider_handles_empty_parameters() {
1172 let tool_no_params = GeminiTool {
1173 function_declarations: vec![GeminiFunctionDeclaration {
1174 name: "ping".to_string(),
1175 description: Some("Ping".to_string()),
1176 parameters_json_schema: None,
1177 parameters: None,
1178 }],
1179 };
1180
1181 let schema: ToolSchema = ToolSchema::from_provider(tool_no_params).unwrap();
1182 assert_eq!(schema.function.name, "ping");
1183 assert!(schema.function.parameters.is_null());
1184 }
1185
1186 #[test]
1187 fn test_tool_roundtrip_preserves_additional_properties() {
1188 let tool = ToolSchema {
1189 schema_type: "function".to_string(),
1190 function: FunctionSchema {
1191 name: "edit".to_string(),
1192 description: "Edit a file".to_string(),
1193 parameters: serde_json::json!({
1194 "type": "object",
1195 "properties": {
1196 "path": { "type": "string" },
1197 "content": { "type": "string" }
1198 },
1199 "required": ["path"],
1200 "additionalProperties": false
1201 }),
1202 },
1203 };
1204
1205 let gemini: GeminiTool = tool.to_provider().unwrap();
1207
1208 let roundtrip: ToolSchema = ToolSchema::from_provider(gemini).unwrap();
1210
1211 assert_eq!(roundtrip.function.name, "edit");
1212 assert_eq!(roundtrip.function.parameters["additionalProperties"], false);
1213 assert_eq!(
1214 roundtrip.function.parameters["required"],
1215 serde_json::json!(["path"])
1216 );
1217 }
1218
1219 #[test]
1220 fn test_empty_parts_has_default() {
1221 let internal = Message::assistant("", None);
1222
1223 let gemini: GeminiContent = internal.to_provider().unwrap();
1224
1225 assert_eq!(gemini.parts.len(), 1);
1227 assert_eq!(gemini.parts[0].text, Some(String::new()));
1228 }
1229}