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