Skip to main content

bamboo_infrastructure/llm/protocol/
gemini.rs

1//! Google Gemini protocol conversion implementation.
2//!
3//! Gemini API has a unique format:
4//! - Messages are called "contents"
5//! - Role is "user" or "model" (not "assistant")
6//! - Content is an array of "parts"
7//! - System instructions are separate from messages
8//!
9//! # Example Gemini Request
10//! ```json
11//! {
12//!   "contents": [
13//!     {
14//!       "role": "user",
15//!       "parts": [{"text": "Hello"}]
16//!     }
17//!   ],
18//!   "systemInstruction": {
19//!     "parts": [{"text": "You are helpful"}]
20//!   },
21//!   "tools": [...]
22//! }
23//! ```
24
25use 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
32/// Gemini protocol converter.
33pub struct GeminiProtocol;
34
35// ============================================================================
36// Gemini API Types
37// ============================================================================
38
39/// Gemini request format
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct GeminiRequest {
42    /// Conversation history
43    pub contents: Vec<GeminiContent>,
44    /// System instructions (extracted from system messages)
45    #[serde(
46        skip_serializing_if = "Option::is_none",
47        rename = "systemInstruction",
48        alias = "system_instruction"
49    )]
50    pub system_instruction: Option<GeminiContent>,
51    /// Available tools
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub tools: Option<Vec<GeminiTool>>,
54    /// Generation config (temperature, max_tokens, etc.)
55    #[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/// Gemini message/content format
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct GeminiContent {
66    /// "user" or "model" (not "assistant")
67    pub role: String,
68    /// Array of content parts
69    pub parts: Vec<GeminiPart>,
70}
71
72/// Gemini content part
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct GeminiPart {
75    /// Text content
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub text: Option<String>,
78    /// Inline base64 image content.
79    #[serde(
80        skip_serializing_if = "Option::is_none",
81        rename = "inlineData",
82        alias = "inline_data"
83    )]
84    pub inline_data: Option<GeminiInlineData>,
85    /// File/URL-based image reference.
86    #[serde(
87        skip_serializing_if = "Option::is_none",
88        rename = "fileData",
89        alias = "file_data"
90    )]
91    pub file_data: Option<GeminiFileData>,
92    /// Function call (for model responses)
93    #[serde(
94        skip_serializing_if = "Option::is_none",
95        rename = "functionCall",
96        alias = "function_call"
97    )]
98    pub function_call: Option<GeminiFunctionCall>,
99    /// Function response (for user/tool messages)
100    #[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/// Gemini inline image payload.
109#[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/// Gemini file image payload.
117#[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/// Gemini function call
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct GeminiFunctionCall {
132    pub name: String,
133    pub args: Value,
134}
135
136/// Gemini function response
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct GeminiFunctionResponse {
139    pub name: String,
140    pub response: Value,
141}
142
143/// Gemini tool definition
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct GeminiTool {
146    #[serde(rename = "functionDeclarations", alias = "function_declarations")]
147    pub function_declarations: Vec<GeminiFunctionDeclaration>,
148}
149
150/// Gemini function declaration (tool schema)
151#[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/// Gemini response format
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct GeminiResponse {
162    pub candidates: Vec<GeminiCandidate>,
163}
164
165/// Gemini response candidate
166#[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
173// ============================================================================
174// Gemini → Internal (FromProvider)
175// ============================================================================
176
177impl 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        // Extract text/image content and tool calls from parts.
187        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()), // Gemini doesn't have IDs
223                    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                // Tool response becomes a tool message
233                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        // Gemini tools can have multiple function declarations
270        // We'll convert the first one
271        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
288// ============================================================================
289// Internal → Gemini (ToProvider)
290// ============================================================================
291
292/// Convert internal messages to Gemini request format.
293///
294/// Note: Gemini extracts system messages to `system_instruction` field.
295pub 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 messages become system_instruction
306                    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        // Handle tool messages specially
335        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", // Already handled above, but kept for completeness
362        };
363
364        let mut parts = Vec::new();
365
366        // Preserve multimodal parts (text + images) when available.
367        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        // Fall back to text projection if there are no explicit parts.
376        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        // Add tool calls as function_call parts
387        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        // Ensure at least one part
406        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
435// ============================================================================
436// Batch conversion for tools
437// ============================================================================
438
439impl ToProvider<Vec<GeminiTool>> for Vec<ToolSchema> {
440    fn to_provider(&self) -> ProtocolResult<Vec<GeminiTool>> {
441        // Gemini groups all function declarations into a single tool
442        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
540// ============================================================================
541// Extension trait for ergonomic conversion
542// ============================================================================
543
544/// Extension trait for Gemini conversion
545pub 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// ============================================================================
571// Tests
572// ============================================================================
573
574#[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        // Gemini → Internal
789        let internal_schema: ToolSchema = ToolSchema::from_provider(gemini_tool.clone()).unwrap();
790        assert_eq!(internal_schema.function.name, "search");
791
792        // Internal → Gemini
793        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        // Gemini groups all tools into one
822        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        // Internal → Gemini
833        let gemini: GeminiContent = original.to_provider().unwrap();
834
835        // Gemini → Internal
836        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        // Should have at least one part with empty text
866        assert_eq!(gemini.parts.len(), 1);
867        assert_eq!(gemini.parts[0].text, Some(String::new()));
868    }
869}