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