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    /// Full JSON Schema for function parameters, sent as `parametersJsonSchema`.
157    /// This field supports the complete JSON Schema spec (including
158    /// `additionalProperties`, `anyOf`, `$ref`, etc.) unlike the older
159    /// `parameters` field which only accepts an OpenAPI 3.0.3 subset.
160    #[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    /// Legacy OpenAPI 3.0.3 `parameters` field. Accepted on deserialization
167    /// for backwards compatibility but not serialized by the internal →
168    /// Gemini direction.
169    #[serde(
170        skip_serializing_if = "Option::is_none",
171        rename = "parameters",
172        alias = "parameters"
173    )]
174    pub parameters: Option<Value>,
175}
176
177/// Gemini response format
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct GeminiResponse {
180    pub candidates: Vec<GeminiCandidate>,
181}
182
183/// Gemini response candidate
184#[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
191// ============================================================================
192// Gemini → Internal (FromProvider)
193// ============================================================================
194
195impl 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        // Extract text/image content and tool calls from parts.
205        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()), // Gemini doesn't have IDs
241                    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                // Tool response becomes a tool message
251                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        // Gemini tools can have multiple function declarations
288        // We'll convert the first one
289        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
311// ============================================================================
312// Internal → Gemini (ToProvider)
313// ============================================================================
314
315/// Convert internal messages to Gemini request format.
316///
317/// Note: Gemini extracts system messages to `system_instruction` field.
318pub 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        // Handle tool messages specially
366        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", // Already handled above, but kept for completeness
393        };
394
395        let mut parts = Vec::new();
396
397        // Preserve multimodal parts (text + images) when available.
398        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        // Fall back to text projection if there are no explicit parts.
407        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        // Add tool calls as function_call parts
418        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        // Ensure at least one part
437        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
467// ============================================================================
468// Batch conversion for tools
469// ============================================================================
470
471impl ToProvider<Vec<GeminiTool>> for Vec<ToolSchema> {
472    fn to_provider(&self) -> ProtocolResult<Vec<GeminiTool>> {
473        // Gemini groups all function declarations into a single tool
474        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
573// ============================================================================
574// Extension trait for ergonomic conversion
575// ============================================================================
576
577/// Extension trait for Gemini conversion
578pub 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// ============================================================================
604// Tests
605// ============================================================================
606
607#[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        // Gemini → Internal
845        let internal_schema: ToolSchema = ToolSchema::from_provider(gemini_tool.clone()).unwrap();
846        assert_eq!(internal_schema.function.name, "search");
847
848        // Internal → Gemini
849        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        // Gemini groups all tools into one
878        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        // Internal → Gemini
889        let gemini: GeminiContent = original.to_provider().unwrap();
890
891        // Gemini → Internal
892        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        // Must use parametersJsonSchema, NOT the legacy parameters field
937        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        // additionalProperties should be preserved (Gemini accepts it in this field)
947        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        // Should pick parametersJsonSchema
1056        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        // Internal → Gemini
1119        let gemini: GeminiTool = tool.to_provider().unwrap();
1120
1121        // Gemini → Internal
1122        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        // Should have at least one part with empty text
1139        assert_eq!(gemini.parts.len(), 1);
1140        assert_eq!(gemini.parts[0].text, Some(String::new()));
1141    }
1142}