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 let mut system_texts = Vec::new();
332 let mut contents = Vec::new();
333
334 for msg in self {
335 match msg.role {
336 Role::System => {
337 let trimmed = msg.content.trim();
338 if !trimmed.is_empty() {
339 system_texts.push(trimmed.to_string());
340 }
341 }
342 _ => {
343 contents.push(msg.to_provider()?);
344 }
345 }
346 }
347
348 let system_instruction = if system_texts.is_empty() {
349 None
350 } else {
351 Some(GeminiContent {
352 role: "system".to_string(),
353 parts: vec![GeminiPart {
354 text: Some(system_texts.join("\n\n")),
355 inline_data: None,
356 file_data: None,
357 function_call: None,
358 function_response: None,
359 }],
360 })
361 };
362
363 Ok(GeminiRequest {
364 contents,
365 system_instruction,
366 tools: None,
367 generation_config: None,
368 })
369 }
370}
371
372impl ToProvider<GeminiContent> for Message {
373 fn to_provider(&self) -> ProtocolResult<GeminiContent> {
374 if self.role == Role::Tool {
376 let tool_name = self
377 .tool_call_id
378 .clone()
379 .ok_or_else(|| ProtocolError::MissingField("tool_call_id".to_string()))?;
380
381 return Ok(GeminiContent {
382 role: "user".to_string(),
383 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 });
397 }
398
399 let role = match self.role {
400 Role::User => "user",
401 Role::Assistant => "model",
402 Role::System => "system",
403 Role::Tool => "user", };
405
406 let mut parts = Vec::new();
407
408 if let Some(content_parts) = self.content_parts.as_ref() {
410 for part in content_parts {
411 if let Some(gemini_part) = message_content_part_to_gemini_part(part) {
412 parts.push(gemini_part);
413 }
414 }
415 }
416
417 if parts.is_empty() && !self.content.is_empty() {
419 parts.push(GeminiPart {
420 text: Some(self.content.clone()),
421 inline_data: None,
422 file_data: None,
423 function_call: None,
424 function_response: None,
425 });
426 }
427
428 if let Some(tool_calls) = &self.tool_calls {
430 for tc in tool_calls {
431 let args: Value = serde_json::from_str(&tc.function.arguments)
432 .unwrap_or_else(|_| Value::Object(serde_json::Map::new()));
433
434 parts.push(GeminiPart {
435 text: None,
436 inline_data: None,
437 file_data: None,
438 function_call: Some(GeminiFunctionCall {
439 name: tc.function.name.clone(),
440 args,
441 }),
442 function_response: None,
443 });
444 }
445 }
446
447 if parts.is_empty() {
449 parts.push(GeminiPart {
450 text: Some(String::new()),
451 inline_data: None,
452 file_data: None,
453 function_call: None,
454 function_response: None,
455 });
456 }
457
458 Ok(GeminiContent {
459 role: role.to_string(),
460 parts,
461 })
462 }
463}
464
465impl ToProvider<GeminiTool> for ToolSchema {
466 fn to_provider(&self) -> ProtocolResult<GeminiTool> {
467 Ok(GeminiTool {
468 function_declarations: vec![GeminiFunctionDeclaration {
469 name: self.function.name.clone(),
470 description: Some(self.function.description.clone()),
471 parameters_json_schema: Some(self.function.parameters.clone()),
472 parameters: None,
473 }],
474 })
475 }
476}
477
478impl ToProvider<Vec<GeminiTool>> for Vec<ToolSchema> {
483 fn to_provider(&self) -> ProtocolResult<Vec<GeminiTool>> {
484 let declarations: Vec<GeminiFunctionDeclaration> = self
486 .iter()
487 .map(|schema| GeminiFunctionDeclaration {
488 name: schema.function.name.clone(),
489 description: Some(schema.function.description.clone()),
490 parameters_json_schema: Some(schema.function.parameters.clone()),
491 parameters: None,
492 })
493 .collect();
494
495 if declarations.is_empty() {
496 Ok(vec![])
497 } else {
498 Ok(vec![GeminiTool {
499 function_declarations: declarations,
500 }])
501 }
502 }
503}
504
505fn message_content_part_to_gemini_part(part: &bamboo_domain::MessagePart) -> Option<GeminiPart> {
506 match part {
507 bamboo_domain::MessagePart::Text { text } => Some(GeminiPart {
508 text: Some(text.clone()),
509 inline_data: None,
510 file_data: None,
511 function_call: None,
512 function_response: None,
513 }),
514 bamboo_domain::MessagePart::ImageUrl { image_url } => {
515 image_url_to_gemini_part(&image_url.url)
516 }
517 }
518}
519
520fn image_url_to_gemini_part(url: &str) -> Option<GeminiPart> {
521 let trimmed = url.trim();
522 if trimmed.is_empty() {
523 return None;
524 }
525
526 if let Some((mime_type, data)) = parse_data_url_base64(trimmed) {
527 return Some(GeminiPart {
528 text: None,
529 inline_data: Some(GeminiInlineData { mime_type, data }),
530 file_data: None,
531 function_call: None,
532 function_response: None,
533 });
534 }
535
536 Some(GeminiPart {
537 text: None,
538 inline_data: None,
539 file_data: Some(GeminiFileData {
540 file_uri: trimmed.to_string(),
541 mime_type: None,
542 }),
543 function_call: None,
544 function_response: None,
545 })
546}
547
548fn parse_data_url_base64(url: &str) -> Option<(String, String)> {
549 let rest = url.strip_prefix("data:")?;
550 let (meta, data) = rest.split_once(',')?;
551 let data = data.trim();
552 if data.is_empty() {
553 return None;
554 }
555
556 let mut mime_type = "application/octet-stream";
557 let mut is_base64 = false;
558 for (idx, seg) in meta.split(';').enumerate() {
559 let segment = seg.trim();
560 if idx == 0 && !segment.is_empty() && !segment.eq_ignore_ascii_case("base64") {
561 mime_type = segment;
562 }
563 if segment.eq_ignore_ascii_case("base64") {
564 is_base64 = true;
565 }
566 }
567
568 if !is_base64 {
569 return None;
570 }
571
572 Some((mime_type.to_string(), data.to_string()))
573}
574
575fn inline_data_to_data_url(inline: &GeminiInlineData) -> Option<String> {
576 let mime_type = inline.mime_type.trim();
577 let data = inline.data.trim();
578 if mime_type.is_empty() || data.is_empty() {
579 return None;
580 }
581 Some(format!("data:{mime_type};base64,{data}"))
582}
583
584pub trait GeminiExt: Sized {
590 fn into_internal(self) -> ProtocolResult<Message>;
591 fn to_gemini(&self) -> ProtocolResult<GeminiContent>;
592}
593
594impl GeminiExt for GeminiContent {
595 fn into_internal(self) -> ProtocolResult<Message> {
596 Message::from_provider(self)
597 }
598
599 fn to_gemini(&self) -> ProtocolResult<GeminiContent> {
600 Ok(self.clone())
601 }
602}
603
604impl GeminiExt for Message {
605 fn into_internal(self) -> ProtocolResult<Message> {
606 Ok(self)
607 }
608
609 fn to_gemini(&self) -> ProtocolResult<GeminiContent> {
610 self.to_provider()
611 }
612}
613
614#[cfg(test)]
619mod tests {
620 use super::*;
621 use crate::models::{ContentPart, ImageUrl};
622 use bamboo_domain::MessagePart;
623
624 #[test]
625 fn test_gemini_to_internal_user_message() {
626 let gemini = GeminiContent {
627 role: "user".to_string(),
628 parts: vec![GeminiPart {
629 text: Some("Hello".to_string()),
630 inline_data: None,
631 file_data: None,
632 function_call: None,
633 function_response: None,
634 }],
635 };
636
637 let internal: Message = Message::from_provider(gemini).unwrap();
638
639 assert_eq!(internal.role, Role::User);
640 assert_eq!(internal.content, "Hello");
641 assert!(internal.tool_calls.is_none());
642 }
643
644 #[test]
645 fn test_internal_to_gemini_user_message() {
646 let internal = Message::user("Hello");
647
648 let gemini: GeminiContent = internal.to_provider().unwrap();
649
650 assert_eq!(gemini.role, "user");
651 assert_eq!(gemini.parts.len(), 1);
652 assert_eq!(gemini.parts[0].text, Some("Hello".to_string()));
653 }
654
655 #[test]
656 fn test_internal_to_gemini_with_data_url_image_part() {
657 let internal = Message::user_with_parts(
658 "describe",
659 vec![
660 ContentPart::Text {
661 text: "describe".to_string(),
662 },
663 ContentPart::ImageUrl {
664 image_url: ImageUrl {
665 url: "data:image/png;base64,AAAA".to_string(),
666 detail: None,
667 },
668 },
669 ]
670 .into_iter()
671 .map(Into::into)
672 .collect(),
673 );
674
675 let gemini: GeminiContent = internal.to_provider().unwrap();
676
677 assert_eq!(gemini.parts.len(), 2);
678 assert_eq!(gemini.parts[0].text, Some("describe".to_string()));
679 let inline = gemini.parts[1]
680 .inline_data
681 .as_ref()
682 .expect("inlineData should be present");
683 assert_eq!(inline.mime_type, "image/png");
684 assert_eq!(inline.data, "AAAA");
685 assert!(gemini.parts[1].file_data.is_none());
686 }
687
688 #[test]
689 fn test_gemini_to_internal_model_message() {
690 let gemini = GeminiContent {
691 role: "model".to_string(),
692 parts: vec![GeminiPart {
693 text: Some("Hello there!".to_string()),
694 inline_data: None,
695 file_data: None,
696 function_call: None,
697 function_response: None,
698 }],
699 };
700
701 let internal: Message = Message::from_provider(gemini).unwrap();
702
703 assert_eq!(internal.role, Role::Assistant);
704 assert_eq!(internal.content, "Hello there!");
705 }
706
707 #[test]
708 fn test_gemini_to_internal_with_inline_data_image() {
709 let gemini = GeminiContent {
710 role: "user".to_string(),
711 parts: vec![GeminiPart {
712 text: Some("look".to_string()),
713 inline_data: Some(GeminiInlineData {
714 mime_type: "image/png".to_string(),
715 data: "BBBB".to_string(),
716 }),
717 file_data: None,
718 function_call: None,
719 function_response: None,
720 }],
721 };
722
723 let internal: Message = Message::from_provider(gemini).unwrap();
724 assert_eq!(internal.content, "look");
725 let parts = internal
726 .content_parts
727 .as_ref()
728 .expect("content_parts should preserve image");
729 assert!(parts.iter().any(|part| {
730 matches!(
731 part,
732 MessagePart::ImageUrl { image_url }
733 if image_url.url == "data:image/png;base64,BBBB"
734 )
735 }));
736 }
737
738 #[test]
739 fn test_internal_to_gemini_with_tool_call() {
740 let tool_call = ToolCall {
741 id: "call_1".to_string(),
742 tool_type: "function".to_string(),
743 function: FunctionCall {
744 name: "search".to_string(),
745 arguments: r#"{"q":"test"}"#.to_string(),
746 },
747 };
748
749 let internal = Message::assistant("Let me search", Some(vec![tool_call]));
750
751 let gemini: GeminiContent = internal.to_provider().unwrap();
752
753 assert_eq!(gemini.role, "model");
754 assert_eq!(gemini.parts.len(), 2);
755 assert_eq!(gemini.parts[0].text, Some("Let me search".to_string()));
756 assert!(gemini.parts[1].function_call.is_some());
757
758 let func_call = gemini.parts[1].function_call.as_ref().unwrap();
759 assert_eq!(func_call.name, "search");
760 assert_eq!(func_call.args, serde_json::json!({"q": "test"}));
761 }
762
763 #[test]
764 fn test_gemini_to_internal_with_tool_call() {
765 let gemini = GeminiContent {
766 role: "model".to_string(),
767 parts: vec![GeminiPart {
768 text: None,
769 inline_data: None,
770 file_data: None,
771 function_call: Some(GeminiFunctionCall {
772 name: "search".to_string(),
773 args: serde_json::json!({"q": "test"}),
774 }),
775 function_response: None,
776 }],
777 };
778
779 let internal: Message = Message::from_provider(gemini).unwrap();
780
781 assert_eq!(internal.role, Role::Assistant);
782 assert!(internal.tool_calls.is_some());
783
784 let tool_calls = internal.tool_calls.unwrap();
785 assert_eq!(tool_calls.len(), 1);
786 assert_eq!(tool_calls[0].function.name, "search");
787 }
788
789 #[test]
790 fn test_system_message_extraction() {
791 let messages = vec![Message::system("You are helpful"), Message::user("Hello")];
792
793 let request: GeminiRequest = messages.to_provider().unwrap();
794
795 assert!(request.system_instruction.is_some());
796 let sys = request.system_instruction.unwrap();
797 assert_eq!(sys.role, "system");
798 assert_eq!(sys.parts[0].text, Some("You are helpful".to_string()));
799
800 assert_eq!(request.contents.len(), 1);
801 assert_eq!(request.contents[0].role, "user");
802 }
803
804 #[test]
805 fn test_multiple_system_messages_are_joined() {
806 let messages = vec![
807 Message::system("You are helpful"),
808 Message::system("Use tools when needed"),
809 Message::user("Hello"),
810 ];
811
812 let request: GeminiRequest = messages.to_provider().unwrap();
813
814 let sys = request
815 .system_instruction
816 .expect("system instruction should be present");
817 assert_eq!(sys.role, "system");
818 assert_eq!(
819 sys.parts[0].text.as_deref(),
820 Some("You are helpful\n\nUse tools when needed")
821 );
822 assert_eq!(request.contents.len(), 1);
823 assert_eq!(request.contents[0].role, "user");
824 }
825
826 #[test]
827 fn test_tool_response_conversion() {
828 let internal = Message::tool_result("search_tool", r#"{"result": "ok"}"#);
829
830 let gemini: GeminiContent = internal.to_provider().unwrap();
831
832 assert_eq!(gemini.role, "user");
833 assert!(gemini.parts[0].function_response.is_some());
834
835 let func_resp = gemini.parts[0].function_response.as_ref().unwrap();
836 assert_eq!(func_resp.name, "search_tool");
837 assert_eq!(func_resp.response, serde_json::json!({ "result": "ok" }));
838 }
839
840 #[test]
841 fn test_plain_text_tool_response_is_wrapped_in_object() {
842 let internal = Message::tool_result("read_file", "plain text output");
843
844 let gemini: GeminiContent = internal.to_provider().unwrap();
845
846 let func_resp = gemini.parts[0].function_response.as_ref().unwrap();
847 assert_eq!(
848 func_resp.response,
849 serde_json::json!({ "result": "plain text output" })
850 );
851 }
852
853 #[test]
854 fn test_non_object_json_tool_response_is_wrapped_in_object() {
855 let internal = Message::tool_result("list_items", r#"["first", "second"]"#);
856
857 let gemini: GeminiContent = internal.to_provider().unwrap();
858
859 let func_resp = gemini.parts[0].function_response.as_ref().unwrap();
860 assert_eq!(
861 func_resp.response,
862 serde_json::json!({ "result": ["first", "second"] })
863 );
864 }
865
866 #[test]
867 fn test_tool_schema_conversion() {
868 let gemini_tool = GeminiTool {
869 function_declarations: vec![GeminiFunctionDeclaration {
870 name: "search".to_string(),
871 description: Some("Search the web".to_string()),
872 parameters_json_schema: Some(serde_json::json!({
873 "type": "object",
874 "properties": {
875 "q": { "type": "string" }
876 }
877 })),
878 parameters: None,
879 }],
880 };
881
882 let internal_schema: ToolSchema = ToolSchema::from_provider(gemini_tool.clone()).unwrap();
884 assert_eq!(internal_schema.function.name, "search");
885
886 let roundtrip: GeminiTool = internal_schema.to_provider().unwrap();
888 assert_eq!(roundtrip.function_declarations.len(), 1);
889 assert_eq!(roundtrip.function_declarations[0].name, "search");
890 }
891
892 #[test]
893 fn test_multiple_tools_grouped() {
894 let tools = vec![
895 ToolSchema {
896 schema_type: "function".to_string(),
897 function: FunctionSchema {
898 name: "search".to_string(),
899 description: "Search".to_string(),
900 parameters: serde_json::json!({"type": "object"}),
901 },
902 },
903 ToolSchema {
904 schema_type: "function".to_string(),
905 function: FunctionSchema {
906 name: "read".to_string(),
907 description: "Read file".to_string(),
908 parameters: serde_json::json!({"type": "object"}),
909 },
910 },
911 ];
912
913 let gemini_tools: Vec<GeminiTool> = tools.to_provider().unwrap();
914
915 assert_eq!(gemini_tools.len(), 1);
917 assert_eq!(gemini_tools[0].function_declarations.len(), 2);
918 assert_eq!(gemini_tools[0].function_declarations[0].name, "search");
919 assert_eq!(gemini_tools[0].function_declarations[1].name, "read");
920 }
921
922 #[test]
923 fn test_roundtrip_conversion() {
924 let original = Message::user("Hello, world!");
925
926 let gemini: GeminiContent = original.to_provider().unwrap();
928
929 let roundtrip: Message = Message::from_provider(gemini).unwrap();
931
932 assert_eq!(roundtrip.role, original.role);
933 assert_eq!(roundtrip.content, original.content);
934 }
935
936 #[test]
937 fn test_invalid_role_error() {
938 let gemini = GeminiContent {
939 role: "invalid_role".to_string(),
940 parts: vec![GeminiPart {
941 text: Some("test".to_string()),
942 inline_data: None,
943 file_data: None,
944 function_call: None,
945 function_response: None,
946 }],
947 };
948
949 let result: ProtocolResult<Message> = Message::from_provider(gemini);
950 assert!(matches!(result, Err(ProtocolError::InvalidRole(_))));
951 }
952
953 #[test]
954 fn test_to_provider_uses_parameters_json_schema_field() {
955 let tool = ToolSchema {
956 schema_type: "function".to_string(),
957 function: FunctionSchema {
958 name: "bash".to_string(),
959 description: "Run a command".to_string(),
960 parameters: serde_json::json!({
961 "type": "object",
962 "properties": {
963 "command": { "type": "string" }
964 },
965 "required": ["command"],
966 "additionalProperties": false
967 }),
968 },
969 };
970
971 let gemini_tool: GeminiTool = tool.to_provider().unwrap();
972 let decl = &gemini_tool.function_declarations[0];
973
974 assert!(
976 decl.parameters_json_schema.is_some(),
977 "parameters_json_schema should be set"
978 );
979 assert!(
980 decl.parameters.is_none(),
981 "legacy parameters field should be None"
982 );
983
984 let schema = decl.parameters_json_schema.as_ref().unwrap();
986 assert_eq!(schema["additionalProperties"], false);
987 assert_eq!(schema["properties"]["command"]["type"], "string");
988 }
989
990 #[test]
991 fn test_to_provider_serializes_as_parameters_json_schema() {
992 let tool = ToolSchema {
993 schema_type: "function".to_string(),
994 function: FunctionSchema {
995 name: "read".to_string(),
996 description: "Read a file".to_string(),
997 parameters: serde_json::json!({
998 "type": "object",
999 "properties": {
1000 "path": { "type": "string" }
1001 },
1002 "additionalProperties": false
1003 }),
1004 },
1005 };
1006
1007 let gemini_tool: GeminiTool = tool.to_provider().unwrap();
1008 let json = serde_json::to_string(&gemini_tool).unwrap();
1009
1010 assert!(
1011 json.contains("parametersJsonSchema"),
1012 "serialized JSON should use 'parametersJsonSchema', got: {json}"
1013 );
1014 assert!(
1015 !json.contains("\"parameters\":"),
1016 "legacy 'parameters' field should not appear in output, got: {json}"
1017 );
1018 assert!(
1019 json.contains("additionalProperties"),
1020 "additionalProperties should be preserved in parametersJsonSchema, got: {json}"
1021 );
1022 }
1023
1024 #[test]
1025 fn test_batch_to_provider_uses_parameters_json_schema() {
1026 let tools = vec![
1027 ToolSchema {
1028 schema_type: "function".to_string(),
1029 function: FunctionSchema {
1030 name: "bash".to_string(),
1031 description: "Run".to_string(),
1032 parameters: serde_json::json!({
1033 "type": "object",
1034 "properties": { "command": { "type": "string" } },
1035 "additionalProperties": false
1036 }),
1037 },
1038 },
1039 ToolSchema {
1040 schema_type: "function".to_string(),
1041 function: FunctionSchema {
1042 name: "read".to_string(),
1043 description: "Read".to_string(),
1044 parameters: serde_json::json!({
1045 "type": "object",
1046 "properties": {
1047 "path": { "type": "string" },
1048 "options": {
1049 "type": "object",
1050 "properties": {
1051 "encoding": { "type": "string" }
1052 },
1053 "additionalProperties": false
1054 }
1055 },
1056 "additionalProperties": false
1057 }),
1058 },
1059 },
1060 ];
1061
1062 let gemini_tools: Vec<GeminiTool> = tools.to_provider().unwrap();
1063 let serialized = serde_json::to_string(&gemini_tools).unwrap();
1064
1065 assert!(
1066 serialized.contains("parametersJsonSchema"),
1067 "should use parametersJsonSchema, got: {serialized}"
1068 );
1069 assert!(
1070 serialized.contains("additionalProperties"),
1071 "additionalProperties should be preserved, got: {serialized}"
1072 );
1073 }
1074
1075 #[test]
1076 fn test_from_provider_prefers_parameters_json_schema_over_parameters() {
1077 let tool_with_both = GeminiTool {
1078 function_declarations: vec![GeminiFunctionDeclaration {
1079 name: "search".to_string(),
1080 description: Some("Search".to_string()),
1081 parameters_json_schema: Some(serde_json::json!({
1082 "type": "object",
1083 "properties": { "q": { "type": "string" } }
1084 })),
1085 parameters: Some(serde_json::json!({
1086 "type": "object",
1087 "properties": { "query": { "type": "string" } }
1088 })),
1089 }],
1090 };
1091
1092 let schema: ToolSchema = ToolSchema::from_provider(tool_with_both).unwrap();
1093 assert_eq!(
1095 schema.function.parameters["properties"]["q"]["type"],
1096 "string"
1097 );
1098 }
1099
1100 #[test]
1101 fn test_from_provider_falls_back_to_legacy_parameters() {
1102 let legacy_tool = GeminiTool {
1103 function_declarations: vec![GeminiFunctionDeclaration {
1104 name: "legacy".to_string(),
1105 description: Some("Legacy tool".to_string()),
1106 parameters_json_schema: None,
1107 parameters: Some(serde_json::json!({
1108 "type": "object",
1109 "properties": { "x": { "type": "integer" } }
1110 })),
1111 }],
1112 };
1113
1114 let schema: ToolSchema = ToolSchema::from_provider(legacy_tool).unwrap();
1115 assert_eq!(
1116 schema.function.parameters["properties"]["x"]["type"],
1117 "integer"
1118 );
1119 }
1120
1121 #[test]
1122 fn test_from_provider_handles_empty_parameters() {
1123 let tool_no_params = GeminiTool {
1124 function_declarations: vec![GeminiFunctionDeclaration {
1125 name: "ping".to_string(),
1126 description: Some("Ping".to_string()),
1127 parameters_json_schema: None,
1128 parameters: None,
1129 }],
1130 };
1131
1132 let schema: ToolSchema = ToolSchema::from_provider(tool_no_params).unwrap();
1133 assert_eq!(schema.function.name, "ping");
1134 assert!(schema.function.parameters.is_null());
1135 }
1136
1137 #[test]
1138 fn test_tool_roundtrip_preserves_additional_properties() {
1139 let tool = ToolSchema {
1140 schema_type: "function".to_string(),
1141 function: FunctionSchema {
1142 name: "edit".to_string(),
1143 description: "Edit a file".to_string(),
1144 parameters: serde_json::json!({
1145 "type": "object",
1146 "properties": {
1147 "path": { "type": "string" },
1148 "content": { "type": "string" }
1149 },
1150 "required": ["path"],
1151 "additionalProperties": false
1152 }),
1153 },
1154 };
1155
1156 let gemini: GeminiTool = tool.to_provider().unwrap();
1158
1159 let roundtrip: ToolSchema = ToolSchema::from_provider(gemini).unwrap();
1161
1162 assert_eq!(roundtrip.function.name, "edit");
1163 assert_eq!(roundtrip.function.parameters["additionalProperties"], false);
1164 assert_eq!(
1165 roundtrip.function.parameters["required"],
1166 serde_json::json!(["path"])
1167 );
1168 }
1169
1170 #[test]
1171 fn test_empty_parts_has_default() {
1172 let internal = Message::assistant("", None);
1173
1174 let gemini: GeminiContent = internal.to_provider().unwrap();
1175
1176 assert_eq!(gemini.parts.len(), 1);
1178 assert_eq!(gemini.parts[0].text, Some(String::new()));
1179 }
1180}