Skip to main content

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