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 pub parameters: Value,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct GeminiResponse {
162 pub candidates: Vec<GeminiCandidate>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct GeminiCandidate {
168 pub content: GeminiContent,
169 #[serde(skip_serializing_if = "Option::is_none")]
170 pub finish_reason: Option<String>,
171}
172
173impl FromProvider<GeminiContent> for Message {
178 fn from_provider(content: GeminiContent) -> ProtocolResult<Self> {
179 let role = match content.role.as_str() {
180 "user" => Role::User,
181 "model" => Role::Assistant,
182 "system" => Role::System,
183 _ => return Err(ProtocolError::InvalidRole(content.role)),
184 };
185
186 let mut text_parts = Vec::new();
188 let mut content_parts = Vec::new();
189 let mut tool_calls = Vec::new();
190 let mut has_image_parts = false;
191
192 for part in content.parts {
193 if let Some(text) = part.text {
194 text_parts.push(text.clone());
195 content_parts.push(bamboo_domain::MessagePart::Text { text });
196 }
197
198 if let Some(inline_data) = part.inline_data {
199 if let Some(url) = inline_data_to_data_url(&inline_data) {
200 has_image_parts = true;
201 content_parts.push(bamboo_domain::MessagePart::ImageUrl {
202 image_url: bamboo_domain::ImageUrlRef { url, detail: None },
203 });
204 }
205 }
206
207 if let Some(file_data) = part.file_data {
208 let file_uri = file_data.file_uri.trim();
209 if !file_uri.is_empty() {
210 has_image_parts = true;
211 content_parts.push(bamboo_domain::MessagePart::ImageUrl {
212 image_url: bamboo_domain::ImageUrlRef {
213 url: file_uri.to_string(),
214 detail: None,
215 },
216 });
217 }
218 }
219
220 if let Some(func_call) = part.function_call {
221 tool_calls.push(ToolCall {
222 id: format!("gemini_{}", uuid::Uuid::new_v4()), tool_type: "function".to_string(),
224 function: FunctionCall {
225 name: func_call.name,
226 arguments: serde_json::to_string(&func_call.args).unwrap_or_default(),
227 },
228 });
229 }
230
231 if let Some(func_response) = part.function_response {
232 return Ok(Message::tool_result(
234 format!("gemini_tool_{}", func_response.name),
235 serde_json::to_string(&func_response.response).unwrap_or_default(),
236 ));
237 }
238 }
239
240 let content_text = text_parts.join("");
241
242 Ok(Message {
243 id: String::new(),
244 role,
245 content: content_text,
246 reasoning: None,
247 content_parts: has_image_parts.then_some(content_parts),
248 image_ocr: None,
249 phase: None,
250 tool_calls: if tool_calls.is_empty() {
251 None
252 } else {
253 Some(tool_calls)
254 },
255 tool_call_id: None,
256 tool_success: None,
257 compressed: false,
258 compressed_by_event_id: None,
259 never_compress: false,
260 compression_level: 0,
261 created_at: chrono::Utc::now(),
262 metadata: None,
263 })
264 }
265}
266
267impl FromProvider<GeminiTool> for ToolSchema {
268 fn from_provider(tool: GeminiTool) -> ProtocolResult<Self> {
269 let func = tool
272 .function_declarations
273 .into_iter()
274 .next()
275 .ok_or_else(|| ProtocolError::InvalidToolCall("Empty tool declarations".to_string()))?;
276
277 Ok(ToolSchema {
278 schema_type: "function".to_string(),
279 function: FunctionSchema {
280 name: func.name,
281 description: func.description.unwrap_or_default(),
282 parameters: func.parameters,
283 },
284 })
285 }
286}
287
288pub struct GeminiRequestBuilder;
296
297impl ToProvider<GeminiRequest> for Vec<Message> {
298 fn to_provider(&self) -> ProtocolResult<GeminiRequest> {
299 let mut system_instruction = None;
300 let mut contents = Vec::new();
301
302 for msg in self {
303 match msg.role {
304 Role::System => {
305 system_instruction = Some(GeminiContent {
307 role: "system".to_string(),
308 parts: vec![GeminiPart {
309 text: Some(msg.content.clone()),
310 inline_data: None,
311 file_data: None,
312 function_call: None,
313 function_response: None,
314 }],
315 });
316 }
317 _ => {
318 contents.push(msg.to_provider()?);
319 }
320 }
321 }
322
323 Ok(GeminiRequest {
324 contents,
325 system_instruction,
326 tools: None,
327 generation_config: None,
328 })
329 }
330}
331
332impl ToProvider<GeminiContent> for Message {
333 fn to_provider(&self) -> ProtocolResult<GeminiContent> {
334 if self.role == Role::Tool {
336 let tool_name = self
337 .tool_call_id
338 .clone()
339 .ok_or_else(|| ProtocolError::MissingField("tool_call_id".to_string()))?;
340
341 return Ok(GeminiContent {
342 role: "user".to_string(),
343 parts: vec![GeminiPart {
344 text: None,
345 inline_data: None,
346 file_data: None,
347 function_call: None,
348 function_response: Some(GeminiFunctionResponse {
349 name: tool_name,
350 response: serde_json::from_str(&self.content)
351 .unwrap_or_else(|_| Value::String(self.content.clone())),
352 }),
353 }],
354 });
355 }
356
357 let role = match self.role {
358 Role::User => "user",
359 Role::Assistant => "model",
360 Role::System => "system",
361 Role::Tool => "user", };
363
364 let mut parts = Vec::new();
365
366 if let Some(content_parts) = self.content_parts.as_ref() {
368 for part in content_parts {
369 if let Some(gemini_part) = message_content_part_to_gemini_part(part) {
370 parts.push(gemini_part);
371 }
372 }
373 }
374
375 if parts.is_empty() && !self.content.is_empty() {
377 parts.push(GeminiPart {
378 text: Some(self.content.clone()),
379 inline_data: None,
380 file_data: None,
381 function_call: None,
382 function_response: None,
383 });
384 }
385
386 if let Some(tool_calls) = &self.tool_calls {
388 for tc in tool_calls {
389 let args: Value = serde_json::from_str(&tc.function.arguments)
390 .unwrap_or_else(|_| Value::Object(serde_json::Map::new()));
391
392 parts.push(GeminiPart {
393 text: None,
394 inline_data: None,
395 file_data: None,
396 function_call: Some(GeminiFunctionCall {
397 name: tc.function.name.clone(),
398 args,
399 }),
400 function_response: None,
401 });
402 }
403 }
404
405 if parts.is_empty() {
407 parts.push(GeminiPart {
408 text: Some(String::new()),
409 inline_data: None,
410 file_data: None,
411 function_call: None,
412 function_response: None,
413 });
414 }
415
416 Ok(GeminiContent {
417 role: role.to_string(),
418 parts,
419 })
420 }
421}
422
423impl ToProvider<GeminiTool> for ToolSchema {
424 fn to_provider(&self) -> ProtocolResult<GeminiTool> {
425 Ok(GeminiTool {
426 function_declarations: vec![GeminiFunctionDeclaration {
427 name: self.function.name.clone(),
428 description: Some(self.function.description.clone()),
429 parameters: self.function.parameters.clone(),
430 }],
431 })
432 }
433}
434
435impl ToProvider<Vec<GeminiTool>> for Vec<ToolSchema> {
440 fn to_provider(&self) -> ProtocolResult<Vec<GeminiTool>> {
441 let declarations: Vec<GeminiFunctionDeclaration> = self
443 .iter()
444 .map(|schema| GeminiFunctionDeclaration {
445 name: schema.function.name.clone(),
446 description: Some(schema.function.description.clone()),
447 parameters: schema.function.parameters.clone(),
448 })
449 .collect();
450
451 if declarations.is_empty() {
452 Ok(vec![])
453 } else {
454 Ok(vec![GeminiTool {
455 function_declarations: declarations,
456 }])
457 }
458 }
459}
460
461fn message_content_part_to_gemini_part(part: &bamboo_domain::MessagePart) -> Option<GeminiPart> {
462 match part {
463 bamboo_domain::MessagePart::Text { text } => Some(GeminiPart {
464 text: Some(text.clone()),
465 inline_data: None,
466 file_data: None,
467 function_call: None,
468 function_response: None,
469 }),
470 bamboo_domain::MessagePart::ImageUrl { image_url } => {
471 image_url_to_gemini_part(&image_url.url)
472 }
473 }
474}
475
476fn image_url_to_gemini_part(url: &str) -> Option<GeminiPart> {
477 let trimmed = url.trim();
478 if trimmed.is_empty() {
479 return None;
480 }
481
482 if let Some((mime_type, data)) = parse_data_url_base64(trimmed) {
483 return Some(GeminiPart {
484 text: None,
485 inline_data: Some(GeminiInlineData { mime_type, data }),
486 file_data: None,
487 function_call: None,
488 function_response: None,
489 });
490 }
491
492 Some(GeminiPart {
493 text: None,
494 inline_data: None,
495 file_data: Some(GeminiFileData {
496 file_uri: trimmed.to_string(),
497 mime_type: None,
498 }),
499 function_call: None,
500 function_response: None,
501 })
502}
503
504fn parse_data_url_base64(url: &str) -> Option<(String, String)> {
505 let rest = url.strip_prefix("data:")?;
506 let (meta, data) = rest.split_once(',')?;
507 let data = data.trim();
508 if data.is_empty() {
509 return None;
510 }
511
512 let mut mime_type = "application/octet-stream";
513 let mut is_base64 = false;
514 for (idx, seg) in meta.split(';').enumerate() {
515 let segment = seg.trim();
516 if idx == 0 && !segment.is_empty() && !segment.eq_ignore_ascii_case("base64") {
517 mime_type = segment;
518 }
519 if segment.eq_ignore_ascii_case("base64") {
520 is_base64 = true;
521 }
522 }
523
524 if !is_base64 {
525 return None;
526 }
527
528 Some((mime_type.to_string(), data.to_string()))
529}
530
531fn inline_data_to_data_url(inline: &GeminiInlineData) -> Option<String> {
532 let mime_type = inline.mime_type.trim();
533 let data = inline.data.trim();
534 if mime_type.is_empty() || data.is_empty() {
535 return None;
536 }
537 Some(format!("data:{mime_type};base64,{data}"))
538}
539
540pub trait GeminiExt: Sized {
546 fn into_internal(self) -> ProtocolResult<Message>;
547 fn to_gemini(&self) -> ProtocolResult<GeminiContent>;
548}
549
550impl GeminiExt for GeminiContent {
551 fn into_internal(self) -> ProtocolResult<Message> {
552 Message::from_provider(self)
553 }
554
555 fn to_gemini(&self) -> ProtocolResult<GeminiContent> {
556 Ok(self.clone())
557 }
558}
559
560impl GeminiExt for Message {
561 fn into_internal(self) -> ProtocolResult<Message> {
562 Ok(self)
563 }
564
565 fn to_gemini(&self) -> ProtocolResult<GeminiContent> {
566 self.to_provider()
567 }
568}
569
570#[cfg(test)]
575mod tests {
576 use super::*;
577 use crate::models::{ContentPart, ImageUrl};
578 use bamboo_domain::MessagePart;
579
580 #[test]
581 fn test_gemini_to_internal_user_message() {
582 let gemini = GeminiContent {
583 role: "user".to_string(),
584 parts: vec![GeminiPart {
585 text: Some("Hello".to_string()),
586 inline_data: None,
587 file_data: None,
588 function_call: None,
589 function_response: None,
590 }],
591 };
592
593 let internal: Message = Message::from_provider(gemini).unwrap();
594
595 assert_eq!(internal.role, Role::User);
596 assert_eq!(internal.content, "Hello");
597 assert!(internal.tool_calls.is_none());
598 }
599
600 #[test]
601 fn test_internal_to_gemini_user_message() {
602 let internal = Message::user("Hello");
603
604 let gemini: GeminiContent = internal.to_provider().unwrap();
605
606 assert_eq!(gemini.role, "user");
607 assert_eq!(gemini.parts.len(), 1);
608 assert_eq!(gemini.parts[0].text, Some("Hello".to_string()));
609 }
610
611 #[test]
612 fn test_internal_to_gemini_with_data_url_image_part() {
613 let internal = Message::user_with_parts(
614 "describe",
615 vec![
616 ContentPart::Text {
617 text: "describe".to_string(),
618 },
619 ContentPart::ImageUrl {
620 image_url: ImageUrl {
621 url: "data:image/png;base64,AAAA".to_string(),
622 detail: None,
623 },
624 },
625 ]
626 .into_iter()
627 .map(Into::into)
628 .collect(),
629 );
630
631 let gemini: GeminiContent = internal.to_provider().unwrap();
632
633 assert_eq!(gemini.parts.len(), 2);
634 assert_eq!(gemini.parts[0].text, Some("describe".to_string()));
635 let inline = gemini.parts[1]
636 .inline_data
637 .as_ref()
638 .expect("inlineData should be present");
639 assert_eq!(inline.mime_type, "image/png");
640 assert_eq!(inline.data, "AAAA");
641 assert!(gemini.parts[1].file_data.is_none());
642 }
643
644 #[test]
645 fn test_gemini_to_internal_model_message() {
646 let gemini = GeminiContent {
647 role: "model".to_string(),
648 parts: vec![GeminiPart {
649 text: Some("Hello there!".to_string()),
650 inline_data: None,
651 file_data: None,
652 function_call: None,
653 function_response: None,
654 }],
655 };
656
657 let internal: Message = Message::from_provider(gemini).unwrap();
658
659 assert_eq!(internal.role, Role::Assistant);
660 assert_eq!(internal.content, "Hello there!");
661 }
662
663 #[test]
664 fn test_gemini_to_internal_with_inline_data_image() {
665 let gemini = GeminiContent {
666 role: "user".to_string(),
667 parts: vec![GeminiPart {
668 text: Some("look".to_string()),
669 inline_data: Some(GeminiInlineData {
670 mime_type: "image/png".to_string(),
671 data: "BBBB".to_string(),
672 }),
673 file_data: None,
674 function_call: None,
675 function_response: None,
676 }],
677 };
678
679 let internal: Message = Message::from_provider(gemini).unwrap();
680 assert_eq!(internal.content, "look");
681 let parts = internal
682 .content_parts
683 .as_ref()
684 .expect("content_parts should preserve image");
685 assert!(parts.iter().any(|part| {
686 matches!(
687 part,
688 MessagePart::ImageUrl { image_url }
689 if image_url.url == "data:image/png;base64,BBBB"
690 )
691 }));
692 }
693
694 #[test]
695 fn test_internal_to_gemini_with_tool_call() {
696 let tool_call = ToolCall {
697 id: "call_1".to_string(),
698 tool_type: "function".to_string(),
699 function: FunctionCall {
700 name: "search".to_string(),
701 arguments: r#"{"q":"test"}"#.to_string(),
702 },
703 };
704
705 let internal = Message::assistant("Let me search", Some(vec![tool_call]));
706
707 let gemini: GeminiContent = internal.to_provider().unwrap();
708
709 assert_eq!(gemini.role, "model");
710 assert_eq!(gemini.parts.len(), 2);
711 assert_eq!(gemini.parts[0].text, Some("Let me search".to_string()));
712 assert!(gemini.parts[1].function_call.is_some());
713
714 let func_call = gemini.parts[1].function_call.as_ref().unwrap();
715 assert_eq!(func_call.name, "search");
716 assert_eq!(func_call.args, serde_json::json!({"q": "test"}));
717 }
718
719 #[test]
720 fn test_gemini_to_internal_with_tool_call() {
721 let gemini = GeminiContent {
722 role: "model".to_string(),
723 parts: vec![GeminiPart {
724 text: None,
725 inline_data: None,
726 file_data: None,
727 function_call: Some(GeminiFunctionCall {
728 name: "search".to_string(),
729 args: serde_json::json!({"q": "test"}),
730 }),
731 function_response: None,
732 }],
733 };
734
735 let internal: Message = Message::from_provider(gemini).unwrap();
736
737 assert_eq!(internal.role, Role::Assistant);
738 assert!(internal.tool_calls.is_some());
739
740 let tool_calls = internal.tool_calls.unwrap();
741 assert_eq!(tool_calls.len(), 1);
742 assert_eq!(tool_calls[0].function.name, "search");
743 }
744
745 #[test]
746 fn test_system_message_extraction() {
747 let messages = vec![Message::system("You are helpful"), Message::user("Hello")];
748
749 let request: GeminiRequest = messages.to_provider().unwrap();
750
751 assert!(request.system_instruction.is_some());
752 let sys = request.system_instruction.unwrap();
753 assert_eq!(sys.role, "system");
754 assert_eq!(sys.parts[0].text, Some("You are helpful".to_string()));
755
756 assert_eq!(request.contents.len(), 1);
757 assert_eq!(request.contents[0].role, "user");
758 }
759
760 #[test]
761 fn test_tool_response_conversion() {
762 let internal = Message::tool_result("search_tool", r#"{"result": "ok"}"#);
763
764 let gemini: GeminiContent = internal.to_provider().unwrap();
765
766 assert_eq!(gemini.role, "user");
767 assert!(gemini.parts[0].function_response.is_some());
768
769 let func_resp = gemini.parts[0].function_response.as_ref().unwrap();
770 assert_eq!(func_resp.name, "search_tool");
771 }
772
773 #[test]
774 fn test_tool_schema_conversion() {
775 let gemini_tool = GeminiTool {
776 function_declarations: vec![GeminiFunctionDeclaration {
777 name: "search".to_string(),
778 description: Some("Search the web".to_string()),
779 parameters: serde_json::json!({
780 "type": "object",
781 "properties": {
782 "q": { "type": "string" }
783 }
784 }),
785 }],
786 };
787
788 let internal_schema: ToolSchema = ToolSchema::from_provider(gemini_tool.clone()).unwrap();
790 assert_eq!(internal_schema.function.name, "search");
791
792 let roundtrip: GeminiTool = internal_schema.to_provider().unwrap();
794 assert_eq!(roundtrip.function_declarations.len(), 1);
795 assert_eq!(roundtrip.function_declarations[0].name, "search");
796 }
797
798 #[test]
799 fn test_multiple_tools_grouped() {
800 let tools = vec![
801 ToolSchema {
802 schema_type: "function".to_string(),
803 function: FunctionSchema {
804 name: "search".to_string(),
805 description: "Search".to_string(),
806 parameters: serde_json::json!({"type": "object"}),
807 },
808 },
809 ToolSchema {
810 schema_type: "function".to_string(),
811 function: FunctionSchema {
812 name: "read".to_string(),
813 description: "Read file".to_string(),
814 parameters: serde_json::json!({"type": "object"}),
815 },
816 },
817 ];
818
819 let gemini_tools: Vec<GeminiTool> = tools.to_provider().unwrap();
820
821 assert_eq!(gemini_tools.len(), 1);
823 assert_eq!(gemini_tools[0].function_declarations.len(), 2);
824 assert_eq!(gemini_tools[0].function_declarations[0].name, "search");
825 assert_eq!(gemini_tools[0].function_declarations[1].name, "read");
826 }
827
828 #[test]
829 fn test_roundtrip_conversion() {
830 let original = Message::user("Hello, world!");
831
832 let gemini: GeminiContent = original.to_provider().unwrap();
834
835 let roundtrip: Message = Message::from_provider(gemini).unwrap();
837
838 assert_eq!(roundtrip.role, original.role);
839 assert_eq!(roundtrip.content, original.content);
840 }
841
842 #[test]
843 fn test_invalid_role_error() {
844 let gemini = GeminiContent {
845 role: "invalid_role".to_string(),
846 parts: vec![GeminiPart {
847 text: Some("test".to_string()),
848 inline_data: None,
849 file_data: None,
850 function_call: None,
851 function_response: None,
852 }],
853 };
854
855 let result: ProtocolResult<Message> = Message::from_provider(gemini);
856 assert!(matches!(result, Err(ProtocolError::InvalidRole(_))));
857 }
858
859 #[test]
860 fn test_empty_parts_has_default() {
861 let internal = Message::assistant("", None);
862
863 let gemini: GeminiContent = internal.to_provider().unwrap();
864
865 assert_eq!(gemini.parts.len(), 1);
867 assert_eq!(gemini.parts[0].text, Some(String::new()));
868 }
869}