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