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
143#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct GeminiTool {
146 #[serde(rename = "functionDeclarations", alias = "function_declarations")]
147 pub function_declarations: Vec<GeminiFunctionDeclaration>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct GeminiFunctionDeclaration {
153 pub name: String,
154 #[serde(skip_serializing_if = "Option::is_none")]
155 pub description: Option<String>,
156 #[serde(
161 skip_serializing_if = "Option::is_none",
162 rename = "parametersJsonSchema",
163 alias = "parameters_json_schema"
164 )]
165 pub parameters_json_schema: Option<Value>,
166 #[serde(
170 skip_serializing_if = "Option::is_none",
171 rename = "parameters",
172 alias = "parameters"
173 )]
174 pub parameters: Option<Value>,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct GeminiResponse {
180 pub candidates: Vec<GeminiCandidate>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct GeminiCandidate {
186 pub content: GeminiContent,
187 #[serde(skip_serializing_if = "Option::is_none")]
188 pub finish_reason: Option<String>,
189}
190
191impl FromProvider<GeminiContent> for Message {
196 fn from_provider(content: GeminiContent) -> ProtocolResult<Self> {
197 let role = match content.role.as_str() {
198 "user" => Role::User,
199 "model" => Role::Assistant,
200 "system" => Role::System,
201 _ => return Err(ProtocolError::InvalidRole(content.role)),
202 };
203
204 let mut text_parts = Vec::new();
206 let mut content_parts = Vec::new();
207 let mut tool_calls = Vec::new();
208 let mut has_image_parts = false;
209
210 for part in content.parts {
211 if let Some(text) = part.text {
212 text_parts.push(text.clone());
213 content_parts.push(bamboo_domain::MessagePart::Text { text });
214 }
215
216 if let Some(inline_data) = part.inline_data {
217 if let Some(url) = inline_data_to_data_url(&inline_data) {
218 has_image_parts = true;
219 content_parts.push(bamboo_domain::MessagePart::ImageUrl {
220 image_url: bamboo_domain::ImageUrlRef { url, detail: None },
221 });
222 }
223 }
224
225 if let Some(file_data) = part.file_data {
226 let file_uri = file_data.file_uri.trim();
227 if !file_uri.is_empty() {
228 has_image_parts = true;
229 content_parts.push(bamboo_domain::MessagePart::ImageUrl {
230 image_url: bamboo_domain::ImageUrlRef {
231 url: file_uri.to_string(),
232 detail: None,
233 },
234 });
235 }
236 }
237
238 if let Some(func_call) = part.function_call {
239 tool_calls.push(ToolCall {
240 id: format!("gemini_{}", uuid::Uuid::new_v4()), tool_type: "function".to_string(),
242 function: FunctionCall {
243 name: func_call.name,
244 arguments: serde_json::to_string(&func_call.args).unwrap_or_default(),
245 },
246 });
247 }
248
249 if let Some(func_response) = part.function_response {
250 return Ok(Message::tool_result(
252 format!("gemini_tool_{}", func_response.name),
253 serde_json::to_string(&func_response.response).unwrap_or_default(),
254 ));
255 }
256 }
257
258 let content_text = text_parts.join("");
259
260 Ok(Message {
261 id: String::new(),
262 role,
263 content: content_text,
264 reasoning: None,
265 reasoning_signature: None,
266 content_parts: has_image_parts.then_some(content_parts),
267 image_ocr: None,
268 phase: None,
269 tool_calls: if tool_calls.is_empty() {
270 None
271 } else {
272 Some(tool_calls)
273 },
274 tool_call_id: None,
275 tool_success: None,
276 compressed: false,
277 compressed_by_event_id: None,
278 never_compress: false,
279 compression_level: 0,
280 created_at: chrono::Utc::now(),
281 metadata: None,
282 })
283 }
284}
285
286impl FromProvider<GeminiTool> for ToolSchema {
287 fn from_provider(tool: GeminiTool) -> ProtocolResult<Self> {
288 let func = tool
291 .function_declarations
292 .into_iter()
293 .next()
294 .ok_or_else(|| ProtocolError::InvalidToolCall("Empty tool declarations".to_string()))?;
295
296 let parameters = func
297 .parameters_json_schema
298 .or(func.parameters)
299 .unwrap_or(Value::Null);
300
301 Ok(ToolSchema {
302 schema_type: "function".to_string(),
303 function: FunctionSchema {
304 name: func.name,
305 description: func.description.unwrap_or_default(),
306 parameters,
307 },
308 })
309 }
310}
311
312pub struct GeminiRequestBuilder;
320
321impl ToProvider<GeminiRequest> for Vec<Message> {
322 fn to_provider(&self) -> ProtocolResult<GeminiRequest> {
323 let mut system_texts = Vec::new();
324 let mut contents = Vec::new();
325
326 for msg in self {
327 match msg.role {
328 Role::System => {
329 let trimmed = msg.content.trim();
330 if !trimmed.is_empty() {
331 system_texts.push(trimmed.to_string());
332 }
333 }
334 _ => {
335 contents.push(msg.to_provider()?);
336 }
337 }
338 }
339
340 let system_instruction = if system_texts.is_empty() {
341 None
342 } else {
343 Some(GeminiContent {
344 role: "system".to_string(),
345 parts: vec![GeminiPart {
346 text: Some(system_texts.join("\n\n")),
347 inline_data: None,
348 file_data: None,
349 function_call: None,
350 function_response: None,
351 }],
352 })
353 };
354
355 Ok(GeminiRequest {
356 contents,
357 system_instruction,
358 tools: None,
359 generation_config: None,
360 })
361 }
362}
363
364impl ToProvider<GeminiContent> for Message {
365 fn to_provider(&self) -> ProtocolResult<GeminiContent> {
366 if self.role == Role::Tool {
368 let tool_name = self
369 .tool_call_id
370 .clone()
371 .ok_or_else(|| ProtocolError::MissingField("tool_call_id".to_string()))?;
372
373 return Ok(GeminiContent {
374 role: "user".to_string(),
375 parts: vec![GeminiPart {
376 text: None,
377 inline_data: None,
378 file_data: None,
379 function_call: None,
380 function_response: Some(GeminiFunctionResponse {
381 name: tool_name,
382 response: serde_json::from_str(&self.content)
383 .unwrap_or_else(|_| Value::String(self.content.clone())),
384 }),
385 }],
386 });
387 }
388
389 let role = match self.role {
390 Role::User => "user",
391 Role::Assistant => "model",
392 Role::System => "system",
393 Role::Tool => "user", };
395
396 let mut parts = Vec::new();
397
398 if let Some(content_parts) = self.content_parts.as_ref() {
400 for part in content_parts {
401 if let Some(gemini_part) = message_content_part_to_gemini_part(part) {
402 parts.push(gemini_part);
403 }
404 }
405 }
406
407 if parts.is_empty() && !self.content.is_empty() {
409 parts.push(GeminiPart {
410 text: Some(self.content.clone()),
411 inline_data: None,
412 file_data: None,
413 function_call: None,
414 function_response: None,
415 });
416 }
417
418 if let Some(tool_calls) = &self.tool_calls {
420 for tc in tool_calls {
421 let args: Value = serde_json::from_str(&tc.function.arguments)
422 .unwrap_or_else(|_| Value::Object(serde_json::Map::new()));
423
424 parts.push(GeminiPart {
425 text: None,
426 inline_data: None,
427 file_data: None,
428 function_call: Some(GeminiFunctionCall {
429 name: tc.function.name.clone(),
430 args,
431 }),
432 function_response: None,
433 });
434 }
435 }
436
437 if parts.is_empty() {
439 parts.push(GeminiPart {
440 text: Some(String::new()),
441 inline_data: None,
442 file_data: None,
443 function_call: None,
444 function_response: None,
445 });
446 }
447
448 Ok(GeminiContent {
449 role: role.to_string(),
450 parts,
451 })
452 }
453}
454
455impl ToProvider<GeminiTool> for ToolSchema {
456 fn to_provider(&self) -> ProtocolResult<GeminiTool> {
457 Ok(GeminiTool {
458 function_declarations: vec![GeminiFunctionDeclaration {
459 name: self.function.name.clone(),
460 description: Some(self.function.description.clone()),
461 parameters_json_schema: Some(self.function.parameters.clone()),
462 parameters: None,
463 }],
464 })
465 }
466}
467
468impl ToProvider<Vec<GeminiTool>> for Vec<ToolSchema> {
473 fn to_provider(&self) -> ProtocolResult<Vec<GeminiTool>> {
474 let declarations: Vec<GeminiFunctionDeclaration> = self
476 .iter()
477 .map(|schema| GeminiFunctionDeclaration {
478 name: schema.function.name.clone(),
479 description: Some(schema.function.description.clone()),
480 parameters_json_schema: Some(schema.function.parameters.clone()),
481 parameters: None,
482 })
483 .collect();
484
485 if declarations.is_empty() {
486 Ok(vec![])
487 } else {
488 Ok(vec![GeminiTool {
489 function_declarations: declarations,
490 }])
491 }
492 }
493}
494
495fn message_content_part_to_gemini_part(part: &bamboo_domain::MessagePart) -> Option<GeminiPart> {
496 match part {
497 bamboo_domain::MessagePart::Text { text } => Some(GeminiPart {
498 text: Some(text.clone()),
499 inline_data: None,
500 file_data: None,
501 function_call: None,
502 function_response: None,
503 }),
504 bamboo_domain::MessagePart::ImageUrl { image_url } => {
505 image_url_to_gemini_part(&image_url.url)
506 }
507 }
508}
509
510fn image_url_to_gemini_part(url: &str) -> Option<GeminiPart> {
511 let trimmed = url.trim();
512 if trimmed.is_empty() {
513 return None;
514 }
515
516 if let Some((mime_type, data)) = parse_data_url_base64(trimmed) {
517 return Some(GeminiPart {
518 text: None,
519 inline_data: Some(GeminiInlineData { mime_type, data }),
520 file_data: None,
521 function_call: None,
522 function_response: None,
523 });
524 }
525
526 Some(GeminiPart {
527 text: None,
528 inline_data: None,
529 file_data: Some(GeminiFileData {
530 file_uri: trimmed.to_string(),
531 mime_type: None,
532 }),
533 function_call: None,
534 function_response: None,
535 })
536}
537
538fn parse_data_url_base64(url: &str) -> Option<(String, String)> {
539 let rest = url.strip_prefix("data:")?;
540 let (meta, data) = rest.split_once(',')?;
541 let data = data.trim();
542 if data.is_empty() {
543 return None;
544 }
545
546 let mut mime_type = "application/octet-stream";
547 let mut is_base64 = false;
548 for (idx, seg) in meta.split(';').enumerate() {
549 let segment = seg.trim();
550 if idx == 0 && !segment.is_empty() && !segment.eq_ignore_ascii_case("base64") {
551 mime_type = segment;
552 }
553 if segment.eq_ignore_ascii_case("base64") {
554 is_base64 = true;
555 }
556 }
557
558 if !is_base64 {
559 return None;
560 }
561
562 Some((mime_type.to_string(), data.to_string()))
563}
564
565fn inline_data_to_data_url(inline: &GeminiInlineData) -> Option<String> {
566 let mime_type = inline.mime_type.trim();
567 let data = inline.data.trim();
568 if mime_type.is_empty() || data.is_empty() {
569 return None;
570 }
571 Some(format!("data:{mime_type};base64,{data}"))
572}
573
574pub trait GeminiExt: Sized {
580 fn into_internal(self) -> ProtocolResult<Message>;
581 fn to_gemini(&self) -> ProtocolResult<GeminiContent>;
582}
583
584impl GeminiExt for GeminiContent {
585 fn into_internal(self) -> ProtocolResult<Message> {
586 Message::from_provider(self)
587 }
588
589 fn to_gemini(&self) -> ProtocolResult<GeminiContent> {
590 Ok(self.clone())
591 }
592}
593
594impl GeminiExt for Message {
595 fn into_internal(self) -> ProtocolResult<Message> {
596 Ok(self)
597 }
598
599 fn to_gemini(&self) -> ProtocolResult<GeminiContent> {
600 self.to_provider()
601 }
602}
603
604#[cfg(test)]
609mod tests {
610 use super::*;
611 use crate::models::{ContentPart, ImageUrl};
612 use bamboo_domain::MessagePart;
613
614 #[test]
615 fn test_gemini_to_internal_user_message() {
616 let gemini = GeminiContent {
617 role: "user".to_string(),
618 parts: vec![GeminiPart {
619 text: Some("Hello".to_string()),
620 inline_data: None,
621 file_data: None,
622 function_call: None,
623 function_response: None,
624 }],
625 };
626
627 let internal: Message = Message::from_provider(gemini).unwrap();
628
629 assert_eq!(internal.role, Role::User);
630 assert_eq!(internal.content, "Hello");
631 assert!(internal.tool_calls.is_none());
632 }
633
634 #[test]
635 fn test_internal_to_gemini_user_message() {
636 let internal = Message::user("Hello");
637
638 let gemini: GeminiContent = internal.to_provider().unwrap();
639
640 assert_eq!(gemini.role, "user");
641 assert_eq!(gemini.parts.len(), 1);
642 assert_eq!(gemini.parts[0].text, Some("Hello".to_string()));
643 }
644
645 #[test]
646 fn test_internal_to_gemini_with_data_url_image_part() {
647 let internal = Message::user_with_parts(
648 "describe",
649 vec![
650 ContentPart::Text {
651 text: "describe".to_string(),
652 },
653 ContentPart::ImageUrl {
654 image_url: ImageUrl {
655 url: "data:image/png;base64,AAAA".to_string(),
656 detail: None,
657 },
658 },
659 ]
660 .into_iter()
661 .map(Into::into)
662 .collect(),
663 );
664
665 let gemini: GeminiContent = internal.to_provider().unwrap();
666
667 assert_eq!(gemini.parts.len(), 2);
668 assert_eq!(gemini.parts[0].text, Some("describe".to_string()));
669 let inline = gemini.parts[1]
670 .inline_data
671 .as_ref()
672 .expect("inlineData should be present");
673 assert_eq!(inline.mime_type, "image/png");
674 assert_eq!(inline.data, "AAAA");
675 assert!(gemini.parts[1].file_data.is_none());
676 }
677
678 #[test]
679 fn test_gemini_to_internal_model_message() {
680 let gemini = GeminiContent {
681 role: "model".to_string(),
682 parts: vec![GeminiPart {
683 text: Some("Hello there!".to_string()),
684 inline_data: None,
685 file_data: None,
686 function_call: None,
687 function_response: None,
688 }],
689 };
690
691 let internal: Message = Message::from_provider(gemini).unwrap();
692
693 assert_eq!(internal.role, Role::Assistant);
694 assert_eq!(internal.content, "Hello there!");
695 }
696
697 #[test]
698 fn test_gemini_to_internal_with_inline_data_image() {
699 let gemini = GeminiContent {
700 role: "user".to_string(),
701 parts: vec![GeminiPart {
702 text: Some("look".to_string()),
703 inline_data: Some(GeminiInlineData {
704 mime_type: "image/png".to_string(),
705 data: "BBBB".to_string(),
706 }),
707 file_data: None,
708 function_call: None,
709 function_response: None,
710 }],
711 };
712
713 let internal: Message = Message::from_provider(gemini).unwrap();
714 assert_eq!(internal.content, "look");
715 let parts = internal
716 .content_parts
717 .as_ref()
718 .expect("content_parts should preserve image");
719 assert!(parts.iter().any(|part| {
720 matches!(
721 part,
722 MessagePart::ImageUrl { image_url }
723 if image_url.url == "data:image/png;base64,BBBB"
724 )
725 }));
726 }
727
728 #[test]
729 fn test_internal_to_gemini_with_tool_call() {
730 let tool_call = ToolCall {
731 id: "call_1".to_string(),
732 tool_type: "function".to_string(),
733 function: FunctionCall {
734 name: "search".to_string(),
735 arguments: r#"{"q":"test"}"#.to_string(),
736 },
737 };
738
739 let internal = Message::assistant("Let me search", Some(vec![tool_call]));
740
741 let gemini: GeminiContent = internal.to_provider().unwrap();
742
743 assert_eq!(gemini.role, "model");
744 assert_eq!(gemini.parts.len(), 2);
745 assert_eq!(gemini.parts[0].text, Some("Let me search".to_string()));
746 assert!(gemini.parts[1].function_call.is_some());
747
748 let func_call = gemini.parts[1].function_call.as_ref().unwrap();
749 assert_eq!(func_call.name, "search");
750 assert_eq!(func_call.args, serde_json::json!({"q": "test"}));
751 }
752
753 #[test]
754 fn test_gemini_to_internal_with_tool_call() {
755 let gemini = GeminiContent {
756 role: "model".to_string(),
757 parts: vec![GeminiPart {
758 text: None,
759 inline_data: None,
760 file_data: None,
761 function_call: Some(GeminiFunctionCall {
762 name: "search".to_string(),
763 args: serde_json::json!({"q": "test"}),
764 }),
765 function_response: None,
766 }],
767 };
768
769 let internal: Message = Message::from_provider(gemini).unwrap();
770
771 assert_eq!(internal.role, Role::Assistant);
772 assert!(internal.tool_calls.is_some());
773
774 let tool_calls = internal.tool_calls.unwrap();
775 assert_eq!(tool_calls.len(), 1);
776 assert_eq!(tool_calls[0].function.name, "search");
777 }
778
779 #[test]
780 fn test_system_message_extraction() {
781 let messages = vec![Message::system("You are helpful"), Message::user("Hello")];
782
783 let request: GeminiRequest = messages.to_provider().unwrap();
784
785 assert!(request.system_instruction.is_some());
786 let sys = request.system_instruction.unwrap();
787 assert_eq!(sys.role, "system");
788 assert_eq!(sys.parts[0].text, Some("You are helpful".to_string()));
789
790 assert_eq!(request.contents.len(), 1);
791 assert_eq!(request.contents[0].role, "user");
792 }
793
794 #[test]
795 fn test_multiple_system_messages_are_joined() {
796 let messages = vec![
797 Message::system("You are helpful"),
798 Message::system("Use tools when needed"),
799 Message::user("Hello"),
800 ];
801
802 let request: GeminiRequest = messages.to_provider().unwrap();
803
804 let sys = request
805 .system_instruction
806 .expect("system instruction should be present");
807 assert_eq!(sys.role, "system");
808 assert_eq!(
809 sys.parts[0].text.as_deref(),
810 Some("You are helpful\n\nUse tools when needed")
811 );
812 assert_eq!(request.contents.len(), 1);
813 assert_eq!(request.contents[0].role, "user");
814 }
815
816 #[test]
817 fn test_tool_response_conversion() {
818 let internal = Message::tool_result("search_tool", r#"{"result": "ok"}"#);
819
820 let gemini: GeminiContent = internal.to_provider().unwrap();
821
822 assert_eq!(gemini.role, "user");
823 assert!(gemini.parts[0].function_response.is_some());
824
825 let func_resp = gemini.parts[0].function_response.as_ref().unwrap();
826 assert_eq!(func_resp.name, "search_tool");
827 }
828
829 #[test]
830 fn test_tool_schema_conversion() {
831 let gemini_tool = GeminiTool {
832 function_declarations: vec![GeminiFunctionDeclaration {
833 name: "search".to_string(),
834 description: Some("Search the web".to_string()),
835 parameters_json_schema: Some(serde_json::json!({
836 "type": "object",
837 "properties": {
838 "q": { "type": "string" }
839 }
840 })),
841 parameters: None,
842 }],
843 };
844
845 let internal_schema: ToolSchema = ToolSchema::from_provider(gemini_tool.clone()).unwrap();
847 assert_eq!(internal_schema.function.name, "search");
848
849 let roundtrip: GeminiTool = internal_schema.to_provider().unwrap();
851 assert_eq!(roundtrip.function_declarations.len(), 1);
852 assert_eq!(roundtrip.function_declarations[0].name, "search");
853 }
854
855 #[test]
856 fn test_multiple_tools_grouped() {
857 let tools = vec![
858 ToolSchema {
859 schema_type: "function".to_string(),
860 function: FunctionSchema {
861 name: "search".to_string(),
862 description: "Search".to_string(),
863 parameters: serde_json::json!({"type": "object"}),
864 },
865 },
866 ToolSchema {
867 schema_type: "function".to_string(),
868 function: FunctionSchema {
869 name: "read".to_string(),
870 description: "Read file".to_string(),
871 parameters: serde_json::json!({"type": "object"}),
872 },
873 },
874 ];
875
876 let gemini_tools: Vec<GeminiTool> = tools.to_provider().unwrap();
877
878 assert_eq!(gemini_tools.len(), 1);
880 assert_eq!(gemini_tools[0].function_declarations.len(), 2);
881 assert_eq!(gemini_tools[0].function_declarations[0].name, "search");
882 assert_eq!(gemini_tools[0].function_declarations[1].name, "read");
883 }
884
885 #[test]
886 fn test_roundtrip_conversion() {
887 let original = Message::user("Hello, world!");
888
889 let gemini: GeminiContent = original.to_provider().unwrap();
891
892 let roundtrip: Message = Message::from_provider(gemini).unwrap();
894
895 assert_eq!(roundtrip.role, original.role);
896 assert_eq!(roundtrip.content, original.content);
897 }
898
899 #[test]
900 fn test_invalid_role_error() {
901 let gemini = GeminiContent {
902 role: "invalid_role".to_string(),
903 parts: vec![GeminiPart {
904 text: Some("test".to_string()),
905 inline_data: None,
906 file_data: None,
907 function_call: None,
908 function_response: None,
909 }],
910 };
911
912 let result: ProtocolResult<Message> = Message::from_provider(gemini);
913 assert!(matches!(result, Err(ProtocolError::InvalidRole(_))));
914 }
915
916 #[test]
917 fn test_to_provider_uses_parameters_json_schema_field() {
918 let tool = ToolSchema {
919 schema_type: "function".to_string(),
920 function: FunctionSchema {
921 name: "bash".to_string(),
922 description: "Run a command".to_string(),
923 parameters: serde_json::json!({
924 "type": "object",
925 "properties": {
926 "command": { "type": "string" }
927 },
928 "required": ["command"],
929 "additionalProperties": false
930 }),
931 },
932 };
933
934 let gemini_tool: GeminiTool = tool.to_provider().unwrap();
935 let decl = &gemini_tool.function_declarations[0];
936
937 assert!(
939 decl.parameters_json_schema.is_some(),
940 "parameters_json_schema should be set"
941 );
942 assert!(
943 decl.parameters.is_none(),
944 "legacy parameters field should be None"
945 );
946
947 let schema = decl.parameters_json_schema.as_ref().unwrap();
949 assert_eq!(schema["additionalProperties"], false);
950 assert_eq!(schema["properties"]["command"]["type"], "string");
951 }
952
953 #[test]
954 fn test_to_provider_serializes_as_parameters_json_schema() {
955 let tool = ToolSchema {
956 schema_type: "function".to_string(),
957 function: FunctionSchema {
958 name: "read".to_string(),
959 description: "Read a file".to_string(),
960 parameters: serde_json::json!({
961 "type": "object",
962 "properties": {
963 "path": { "type": "string" }
964 },
965 "additionalProperties": false
966 }),
967 },
968 };
969
970 let gemini_tool: GeminiTool = tool.to_provider().unwrap();
971 let json = serde_json::to_string(&gemini_tool).unwrap();
972
973 assert!(
974 json.contains("parametersJsonSchema"),
975 "serialized JSON should use 'parametersJsonSchema', got: {json}"
976 );
977 assert!(
978 !json.contains("\"parameters\":"),
979 "legacy 'parameters' field should not appear in output, got: {json}"
980 );
981 assert!(
982 json.contains("additionalProperties"),
983 "additionalProperties should be preserved in parametersJsonSchema, got: {json}"
984 );
985 }
986
987 #[test]
988 fn test_batch_to_provider_uses_parameters_json_schema() {
989 let tools = vec![
990 ToolSchema {
991 schema_type: "function".to_string(),
992 function: FunctionSchema {
993 name: "bash".to_string(),
994 description: "Run".to_string(),
995 parameters: serde_json::json!({
996 "type": "object",
997 "properties": { "command": { "type": "string" } },
998 "additionalProperties": false
999 }),
1000 },
1001 },
1002 ToolSchema {
1003 schema_type: "function".to_string(),
1004 function: FunctionSchema {
1005 name: "read".to_string(),
1006 description: "Read".to_string(),
1007 parameters: serde_json::json!({
1008 "type": "object",
1009 "properties": {
1010 "path": { "type": "string" },
1011 "options": {
1012 "type": "object",
1013 "properties": {
1014 "encoding": { "type": "string" }
1015 },
1016 "additionalProperties": false
1017 }
1018 },
1019 "additionalProperties": false
1020 }),
1021 },
1022 },
1023 ];
1024
1025 let gemini_tools: Vec<GeminiTool> = tools.to_provider().unwrap();
1026 let serialized = serde_json::to_string(&gemini_tools).unwrap();
1027
1028 assert!(
1029 serialized.contains("parametersJsonSchema"),
1030 "should use parametersJsonSchema, got: {serialized}"
1031 );
1032 assert!(
1033 serialized.contains("additionalProperties"),
1034 "additionalProperties should be preserved, got: {serialized}"
1035 );
1036 }
1037
1038 #[test]
1039 fn test_from_provider_prefers_parameters_json_schema_over_parameters() {
1040 let tool_with_both = GeminiTool {
1041 function_declarations: vec![GeminiFunctionDeclaration {
1042 name: "search".to_string(),
1043 description: Some("Search".to_string()),
1044 parameters_json_schema: Some(serde_json::json!({
1045 "type": "object",
1046 "properties": { "q": { "type": "string" } }
1047 })),
1048 parameters: Some(serde_json::json!({
1049 "type": "object",
1050 "properties": { "query": { "type": "string" } }
1051 })),
1052 }],
1053 };
1054
1055 let schema: ToolSchema = ToolSchema::from_provider(tool_with_both).unwrap();
1056 assert_eq!(
1058 schema.function.parameters["properties"]["q"]["type"],
1059 "string"
1060 );
1061 }
1062
1063 #[test]
1064 fn test_from_provider_falls_back_to_legacy_parameters() {
1065 let legacy_tool = GeminiTool {
1066 function_declarations: vec![GeminiFunctionDeclaration {
1067 name: "legacy".to_string(),
1068 description: Some("Legacy tool".to_string()),
1069 parameters_json_schema: None,
1070 parameters: Some(serde_json::json!({
1071 "type": "object",
1072 "properties": { "x": { "type": "integer" } }
1073 })),
1074 }],
1075 };
1076
1077 let schema: ToolSchema = ToolSchema::from_provider(legacy_tool).unwrap();
1078 assert_eq!(
1079 schema.function.parameters["properties"]["x"]["type"],
1080 "integer"
1081 );
1082 }
1083
1084 #[test]
1085 fn test_from_provider_handles_empty_parameters() {
1086 let tool_no_params = GeminiTool {
1087 function_declarations: vec![GeminiFunctionDeclaration {
1088 name: "ping".to_string(),
1089 description: Some("Ping".to_string()),
1090 parameters_json_schema: None,
1091 parameters: None,
1092 }],
1093 };
1094
1095 let schema: ToolSchema = ToolSchema::from_provider(tool_no_params).unwrap();
1096 assert_eq!(schema.function.name, "ping");
1097 assert!(schema.function.parameters.is_null());
1098 }
1099
1100 #[test]
1101 fn test_tool_roundtrip_preserves_additional_properties() {
1102 let tool = ToolSchema {
1103 schema_type: "function".to_string(),
1104 function: FunctionSchema {
1105 name: "edit".to_string(),
1106 description: "Edit a file".to_string(),
1107 parameters: serde_json::json!({
1108 "type": "object",
1109 "properties": {
1110 "path": { "type": "string" },
1111 "content": { "type": "string" }
1112 },
1113 "required": ["path"],
1114 "additionalProperties": false
1115 }),
1116 },
1117 };
1118
1119 let gemini: GeminiTool = tool.to_provider().unwrap();
1121
1122 let roundtrip: ToolSchema = ToolSchema::from_provider(gemini).unwrap();
1124
1125 assert_eq!(roundtrip.function.name, "edit");
1126 assert_eq!(roundtrip.function.parameters["additionalProperties"], false);
1127 assert_eq!(
1128 roundtrip.function.parameters["required"],
1129 serde_json::json!(["path"])
1130 );
1131 }
1132
1133 #[test]
1134 fn test_empty_parts_has_default() {
1135 let internal = Message::assistant("", None);
1136
1137 let gemini: GeminiContent = internal.to_provider().unwrap();
1138
1139 assert_eq!(gemini.parts.len(), 1);
1141 assert_eq!(gemini.parts[0].text, Some(String::new()));
1142 }
1143}