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(self.function.parameters.clone()),
472                parameters: None,
473            }],
474        })
475    }
476}
477
478// ============================================================================
479// Batch conversion for tools
480// ============================================================================
481
482impl ToProvider<Vec<GeminiTool>> for Vec<ToolSchema> {
483    fn to_provider(&self) -> ProtocolResult<Vec<GeminiTool>> {
484        // Gemini groups all function declarations into a single tool
485        let declarations: Vec<GeminiFunctionDeclaration> = self
486            .iter()
487            .map(|schema| GeminiFunctionDeclaration {
488                name: schema.function.name.clone(),
489                description: Some(schema.function.description.clone()),
490                parameters_json_schema: Some(schema.function.parameters.clone()),
491                parameters: None,
492            })
493            .collect();
494
495        if declarations.is_empty() {
496            Ok(vec![])
497        } else {
498            Ok(vec![GeminiTool {
499                function_declarations: declarations,
500            }])
501        }
502    }
503}
504
505fn message_content_part_to_gemini_part(part: &bamboo_domain::MessagePart) -> Option<GeminiPart> {
506    match part {
507        bamboo_domain::MessagePart::Text { text } => Some(GeminiPart {
508            text: Some(text.clone()),
509            inline_data: None,
510            file_data: None,
511            function_call: None,
512            function_response: None,
513        }),
514        bamboo_domain::MessagePart::ImageUrl { image_url } => {
515            image_url_to_gemini_part(&image_url.url)
516        }
517    }
518}
519
520fn image_url_to_gemini_part(url: &str) -> Option<GeminiPart> {
521    let trimmed = url.trim();
522    if trimmed.is_empty() {
523        return None;
524    }
525
526    if let Some((mime_type, data)) = parse_data_url_base64(trimmed) {
527        return Some(GeminiPart {
528            text: None,
529            inline_data: Some(GeminiInlineData { mime_type, data }),
530            file_data: None,
531            function_call: None,
532            function_response: None,
533        });
534    }
535
536    Some(GeminiPart {
537        text: None,
538        inline_data: None,
539        file_data: Some(GeminiFileData {
540            file_uri: trimmed.to_string(),
541            mime_type: None,
542        }),
543        function_call: None,
544        function_response: None,
545    })
546}
547
548fn parse_data_url_base64(url: &str) -> Option<(String, String)> {
549    let rest = url.strip_prefix("data:")?;
550    let (meta, data) = rest.split_once(',')?;
551    let data = data.trim();
552    if data.is_empty() {
553        return None;
554    }
555
556    let mut mime_type = "application/octet-stream";
557    let mut is_base64 = false;
558    for (idx, seg) in meta.split(';').enumerate() {
559        let segment = seg.trim();
560        if idx == 0 && !segment.is_empty() && !segment.eq_ignore_ascii_case("base64") {
561            mime_type = segment;
562        }
563        if segment.eq_ignore_ascii_case("base64") {
564            is_base64 = true;
565        }
566    }
567
568    if !is_base64 {
569        return None;
570    }
571
572    Some((mime_type.to_string(), data.to_string()))
573}
574
575fn inline_data_to_data_url(inline: &GeminiInlineData) -> Option<String> {
576    let mime_type = inline.mime_type.trim();
577    let data = inline.data.trim();
578    if mime_type.is_empty() || data.is_empty() {
579        return None;
580    }
581    Some(format!("data:{mime_type};base64,{data}"))
582}
583
584// ============================================================================
585// Extension trait for ergonomic conversion
586// ============================================================================
587
588/// Extension trait for Gemini conversion
589pub trait GeminiExt: Sized {
590    fn into_internal(self) -> ProtocolResult<Message>;
591    fn to_gemini(&self) -> ProtocolResult<GeminiContent>;
592}
593
594impl GeminiExt for GeminiContent {
595    fn into_internal(self) -> ProtocolResult<Message> {
596        Message::from_provider(self)
597    }
598
599    fn to_gemini(&self) -> ProtocolResult<GeminiContent> {
600        Ok(self.clone())
601    }
602}
603
604impl GeminiExt for Message {
605    fn into_internal(self) -> ProtocolResult<Message> {
606        Ok(self)
607    }
608
609    fn to_gemini(&self) -> ProtocolResult<GeminiContent> {
610        self.to_provider()
611    }
612}
613
614// ============================================================================
615// Tests
616// ============================================================================
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use crate::models::{ContentPart, ImageUrl};
622    use bamboo_domain::MessagePart;
623
624    #[test]
625    fn test_gemini_to_internal_user_message() {
626        let gemini = GeminiContent {
627            role: "user".to_string(),
628            parts: vec![GeminiPart {
629                text: Some("Hello".to_string()),
630                inline_data: None,
631                file_data: None,
632                function_call: None,
633                function_response: None,
634            }],
635        };
636
637        let internal: Message = Message::from_provider(gemini).unwrap();
638
639        assert_eq!(internal.role, Role::User);
640        assert_eq!(internal.content, "Hello");
641        assert!(internal.tool_calls.is_none());
642    }
643
644    #[test]
645    fn test_internal_to_gemini_user_message() {
646        let internal = Message::user("Hello");
647
648        let gemini: GeminiContent = internal.to_provider().unwrap();
649
650        assert_eq!(gemini.role, "user");
651        assert_eq!(gemini.parts.len(), 1);
652        assert_eq!(gemini.parts[0].text, Some("Hello".to_string()));
653    }
654
655    #[test]
656    fn test_internal_to_gemini_with_data_url_image_part() {
657        let internal = Message::user_with_parts(
658            "describe",
659            vec![
660                ContentPart::Text {
661                    text: "describe".to_string(),
662                },
663                ContentPart::ImageUrl {
664                    image_url: ImageUrl {
665                        url: "data:image/png;base64,AAAA".to_string(),
666                        detail: None,
667                    },
668                },
669            ]
670            .into_iter()
671            .map(Into::into)
672            .collect(),
673        );
674
675        let gemini: GeminiContent = internal.to_provider().unwrap();
676
677        assert_eq!(gemini.parts.len(), 2);
678        assert_eq!(gemini.parts[0].text, Some("describe".to_string()));
679        let inline = gemini.parts[1]
680            .inline_data
681            .as_ref()
682            .expect("inlineData should be present");
683        assert_eq!(inline.mime_type, "image/png");
684        assert_eq!(inline.data, "AAAA");
685        assert!(gemini.parts[1].file_data.is_none());
686    }
687
688    #[test]
689    fn test_gemini_to_internal_model_message() {
690        let gemini = GeminiContent {
691            role: "model".to_string(),
692            parts: vec![GeminiPart {
693                text: Some("Hello there!".to_string()),
694                inline_data: None,
695                file_data: None,
696                function_call: None,
697                function_response: None,
698            }],
699        };
700
701        let internal: Message = Message::from_provider(gemini).unwrap();
702
703        assert_eq!(internal.role, Role::Assistant);
704        assert_eq!(internal.content, "Hello there!");
705    }
706
707    #[test]
708    fn test_gemini_to_internal_with_inline_data_image() {
709        let gemini = GeminiContent {
710            role: "user".to_string(),
711            parts: vec![GeminiPart {
712                text: Some("look".to_string()),
713                inline_data: Some(GeminiInlineData {
714                    mime_type: "image/png".to_string(),
715                    data: "BBBB".to_string(),
716                }),
717                file_data: None,
718                function_call: None,
719                function_response: None,
720            }],
721        };
722
723        let internal: Message = Message::from_provider(gemini).unwrap();
724        assert_eq!(internal.content, "look");
725        let parts = internal
726            .content_parts
727            .as_ref()
728            .expect("content_parts should preserve image");
729        assert!(parts.iter().any(|part| {
730            matches!(
731                part,
732                MessagePart::ImageUrl { image_url }
733                if image_url.url == "data:image/png;base64,BBBB"
734            )
735        }));
736    }
737
738    #[test]
739    fn test_internal_to_gemini_with_tool_call() {
740        let tool_call = ToolCall {
741            id: "call_1".to_string(),
742            tool_type: "function".to_string(),
743            function: FunctionCall {
744                name: "search".to_string(),
745                arguments: r#"{"q":"test"}"#.to_string(),
746            },
747        };
748
749        let internal = Message::assistant("Let me search", Some(vec![tool_call]));
750
751        let gemini: GeminiContent = internal.to_provider().unwrap();
752
753        assert_eq!(gemini.role, "model");
754        assert_eq!(gemini.parts.len(), 2);
755        assert_eq!(gemini.parts[0].text, Some("Let me search".to_string()));
756        assert!(gemini.parts[1].function_call.is_some());
757
758        let func_call = gemini.parts[1].function_call.as_ref().unwrap();
759        assert_eq!(func_call.name, "search");
760        assert_eq!(func_call.args, serde_json::json!({"q": "test"}));
761    }
762
763    #[test]
764    fn test_gemini_to_internal_with_tool_call() {
765        let gemini = GeminiContent {
766            role: "model".to_string(),
767            parts: vec![GeminiPart {
768                text: None,
769                inline_data: None,
770                file_data: None,
771                function_call: Some(GeminiFunctionCall {
772                    name: "search".to_string(),
773                    args: serde_json::json!({"q": "test"}),
774                }),
775                function_response: None,
776            }],
777        };
778
779        let internal: Message = Message::from_provider(gemini).unwrap();
780
781        assert_eq!(internal.role, Role::Assistant);
782        assert!(internal.tool_calls.is_some());
783
784        let tool_calls = internal.tool_calls.unwrap();
785        assert_eq!(tool_calls.len(), 1);
786        assert_eq!(tool_calls[0].function.name, "search");
787    }
788
789    #[test]
790    fn test_system_message_extraction() {
791        let messages = vec![Message::system("You are helpful"), Message::user("Hello")];
792
793        let request: GeminiRequest = messages.to_provider().unwrap();
794
795        assert!(request.system_instruction.is_some());
796        let sys = request.system_instruction.unwrap();
797        assert_eq!(sys.role, "system");
798        assert_eq!(sys.parts[0].text, Some("You are helpful".to_string()));
799
800        assert_eq!(request.contents.len(), 1);
801        assert_eq!(request.contents[0].role, "user");
802    }
803
804    #[test]
805    fn test_multiple_system_messages_are_joined() {
806        let messages = vec![
807            Message::system("You are helpful"),
808            Message::system("Use tools when needed"),
809            Message::user("Hello"),
810        ];
811
812        let request: GeminiRequest = messages.to_provider().unwrap();
813
814        let sys = request
815            .system_instruction
816            .expect("system instruction should be present");
817        assert_eq!(sys.role, "system");
818        assert_eq!(
819            sys.parts[0].text.as_deref(),
820            Some("You are helpful\n\nUse tools when needed")
821        );
822        assert_eq!(request.contents.len(), 1);
823        assert_eq!(request.contents[0].role, "user");
824    }
825
826    #[test]
827    fn test_tool_response_conversion() {
828        let internal = Message::tool_result("search_tool", r#"{"result": "ok"}"#);
829
830        let gemini: GeminiContent = internal.to_provider().unwrap();
831
832        assert_eq!(gemini.role, "user");
833        assert!(gemini.parts[0].function_response.is_some());
834
835        let func_resp = gemini.parts[0].function_response.as_ref().unwrap();
836        assert_eq!(func_resp.name, "search_tool");
837        assert_eq!(func_resp.response, serde_json::json!({ "result": "ok" }));
838    }
839
840    #[test]
841    fn test_plain_text_tool_response_is_wrapped_in_object() {
842        let internal = Message::tool_result("read_file", "plain text output");
843
844        let gemini: GeminiContent = internal.to_provider().unwrap();
845
846        let func_resp = gemini.parts[0].function_response.as_ref().unwrap();
847        assert_eq!(
848            func_resp.response,
849            serde_json::json!({ "result": "plain text output" })
850        );
851    }
852
853    #[test]
854    fn test_non_object_json_tool_response_is_wrapped_in_object() {
855        let internal = Message::tool_result("list_items", r#"["first", "second"]"#);
856
857        let gemini: GeminiContent = internal.to_provider().unwrap();
858
859        let func_resp = gemini.parts[0].function_response.as_ref().unwrap();
860        assert_eq!(
861            func_resp.response,
862            serde_json::json!({ "result": ["first", "second"] })
863        );
864    }
865
866    #[test]
867    fn test_tool_schema_conversion() {
868        let gemini_tool = GeminiTool {
869            function_declarations: vec![GeminiFunctionDeclaration {
870                name: "search".to_string(),
871                description: Some("Search the web".to_string()),
872                parameters_json_schema: Some(serde_json::json!({
873                    "type": "object",
874                    "properties": {
875                        "q": { "type": "string" }
876                    }
877                })),
878                parameters: None,
879            }],
880        };
881
882        // Gemini → Internal
883        let internal_schema: ToolSchema = ToolSchema::from_provider(gemini_tool.clone()).unwrap();
884        assert_eq!(internal_schema.function.name, "search");
885
886        // Internal → Gemini
887        let roundtrip: GeminiTool = internal_schema.to_provider().unwrap();
888        assert_eq!(roundtrip.function_declarations.len(), 1);
889        assert_eq!(roundtrip.function_declarations[0].name, "search");
890    }
891
892    #[test]
893    fn test_multiple_tools_grouped() {
894        let tools = vec![
895            ToolSchema {
896                schema_type: "function".to_string(),
897                function: FunctionSchema {
898                    name: "search".to_string(),
899                    description: "Search".to_string(),
900                    parameters: serde_json::json!({"type": "object"}),
901                },
902            },
903            ToolSchema {
904                schema_type: "function".to_string(),
905                function: FunctionSchema {
906                    name: "read".to_string(),
907                    description: "Read file".to_string(),
908                    parameters: serde_json::json!({"type": "object"}),
909                },
910            },
911        ];
912
913        let gemini_tools: Vec<GeminiTool> = tools.to_provider().unwrap();
914
915        // Gemini groups all tools into one
916        assert_eq!(gemini_tools.len(), 1);
917        assert_eq!(gemini_tools[0].function_declarations.len(), 2);
918        assert_eq!(gemini_tools[0].function_declarations[0].name, "search");
919        assert_eq!(gemini_tools[0].function_declarations[1].name, "read");
920    }
921
922    #[test]
923    fn test_roundtrip_conversion() {
924        let original = Message::user("Hello, world!");
925
926        // Internal → Gemini
927        let gemini: GeminiContent = original.to_provider().unwrap();
928
929        // Gemini → Internal
930        let roundtrip: Message = Message::from_provider(gemini).unwrap();
931
932        assert_eq!(roundtrip.role, original.role);
933        assert_eq!(roundtrip.content, original.content);
934    }
935
936    #[test]
937    fn test_invalid_role_error() {
938        let gemini = GeminiContent {
939            role: "invalid_role".to_string(),
940            parts: vec![GeminiPart {
941                text: Some("test".to_string()),
942                inline_data: None,
943                file_data: None,
944                function_call: None,
945                function_response: None,
946            }],
947        };
948
949        let result: ProtocolResult<Message> = Message::from_provider(gemini);
950        assert!(matches!(result, Err(ProtocolError::InvalidRole(_))));
951    }
952
953    #[test]
954    fn test_to_provider_uses_parameters_json_schema_field() {
955        let tool = ToolSchema {
956            schema_type: "function".to_string(),
957            function: FunctionSchema {
958                name: "bash".to_string(),
959                description: "Run a command".to_string(),
960                parameters: serde_json::json!({
961                    "type": "object",
962                    "properties": {
963                        "command": { "type": "string" }
964                    },
965                    "required": ["command"],
966                    "additionalProperties": false
967                }),
968            },
969        };
970
971        let gemini_tool: GeminiTool = tool.to_provider().unwrap();
972        let decl = &gemini_tool.function_declarations[0];
973
974        // Must use parametersJsonSchema, NOT the legacy parameters field
975        assert!(
976            decl.parameters_json_schema.is_some(),
977            "parameters_json_schema should be set"
978        );
979        assert!(
980            decl.parameters.is_none(),
981            "legacy parameters field should be None"
982        );
983
984        // additionalProperties should be preserved (Gemini accepts it in this field)
985        let schema = decl.parameters_json_schema.as_ref().unwrap();
986        assert_eq!(schema["additionalProperties"], false);
987        assert_eq!(schema["properties"]["command"]["type"], "string");
988    }
989
990    #[test]
991    fn test_to_provider_serializes_as_parameters_json_schema() {
992        let tool = ToolSchema {
993            schema_type: "function".to_string(),
994            function: FunctionSchema {
995                name: "read".to_string(),
996                description: "Read a file".to_string(),
997                parameters: serde_json::json!({
998                    "type": "object",
999                    "properties": {
1000                        "path": { "type": "string" }
1001                    },
1002                    "additionalProperties": false
1003                }),
1004            },
1005        };
1006
1007        let gemini_tool: GeminiTool = tool.to_provider().unwrap();
1008        let json = serde_json::to_string(&gemini_tool).unwrap();
1009
1010        assert!(
1011            json.contains("parametersJsonSchema"),
1012            "serialized JSON should use 'parametersJsonSchema', got: {json}"
1013        );
1014        assert!(
1015            !json.contains("\"parameters\":"),
1016            "legacy 'parameters' field should not appear in output, got: {json}"
1017        );
1018        assert!(
1019            json.contains("additionalProperties"),
1020            "additionalProperties should be preserved in parametersJsonSchema, got: {json}"
1021        );
1022    }
1023
1024    #[test]
1025    fn test_batch_to_provider_uses_parameters_json_schema() {
1026        let tools = vec![
1027            ToolSchema {
1028                schema_type: "function".to_string(),
1029                function: FunctionSchema {
1030                    name: "bash".to_string(),
1031                    description: "Run".to_string(),
1032                    parameters: serde_json::json!({
1033                        "type": "object",
1034                        "properties": { "command": { "type": "string" } },
1035                        "additionalProperties": false
1036                    }),
1037                },
1038            },
1039            ToolSchema {
1040                schema_type: "function".to_string(),
1041                function: FunctionSchema {
1042                    name: "read".to_string(),
1043                    description: "Read".to_string(),
1044                    parameters: serde_json::json!({
1045                        "type": "object",
1046                        "properties": {
1047                            "path": { "type": "string" },
1048                            "options": {
1049                                "type": "object",
1050                                "properties": {
1051                                    "encoding": { "type": "string" }
1052                                },
1053                                "additionalProperties": false
1054                            }
1055                        },
1056                        "additionalProperties": false
1057                    }),
1058                },
1059            },
1060        ];
1061
1062        let gemini_tools: Vec<GeminiTool> = tools.to_provider().unwrap();
1063        let serialized = serde_json::to_string(&gemini_tools).unwrap();
1064
1065        assert!(
1066            serialized.contains("parametersJsonSchema"),
1067            "should use parametersJsonSchema, got: {serialized}"
1068        );
1069        assert!(
1070            serialized.contains("additionalProperties"),
1071            "additionalProperties should be preserved, got: {serialized}"
1072        );
1073    }
1074
1075    #[test]
1076    fn test_from_provider_prefers_parameters_json_schema_over_parameters() {
1077        let tool_with_both = GeminiTool {
1078            function_declarations: vec![GeminiFunctionDeclaration {
1079                name: "search".to_string(),
1080                description: Some("Search".to_string()),
1081                parameters_json_schema: Some(serde_json::json!({
1082                    "type": "object",
1083                    "properties": { "q": { "type": "string" } }
1084                })),
1085                parameters: Some(serde_json::json!({
1086                    "type": "object",
1087                    "properties": { "query": { "type": "string" } }
1088                })),
1089            }],
1090        };
1091
1092        let schema: ToolSchema = ToolSchema::from_provider(tool_with_both).unwrap();
1093        // Should pick parametersJsonSchema
1094        assert_eq!(
1095            schema.function.parameters["properties"]["q"]["type"],
1096            "string"
1097        );
1098    }
1099
1100    #[test]
1101    fn test_from_provider_falls_back_to_legacy_parameters() {
1102        let legacy_tool = GeminiTool {
1103            function_declarations: vec![GeminiFunctionDeclaration {
1104                name: "legacy".to_string(),
1105                description: Some("Legacy tool".to_string()),
1106                parameters_json_schema: None,
1107                parameters: Some(serde_json::json!({
1108                    "type": "object",
1109                    "properties": { "x": { "type": "integer" } }
1110                })),
1111            }],
1112        };
1113
1114        let schema: ToolSchema = ToolSchema::from_provider(legacy_tool).unwrap();
1115        assert_eq!(
1116            schema.function.parameters["properties"]["x"]["type"],
1117            "integer"
1118        );
1119    }
1120
1121    #[test]
1122    fn test_from_provider_handles_empty_parameters() {
1123        let tool_no_params = GeminiTool {
1124            function_declarations: vec![GeminiFunctionDeclaration {
1125                name: "ping".to_string(),
1126                description: Some("Ping".to_string()),
1127                parameters_json_schema: None,
1128                parameters: None,
1129            }],
1130        };
1131
1132        let schema: ToolSchema = ToolSchema::from_provider(tool_no_params).unwrap();
1133        assert_eq!(schema.function.name, "ping");
1134        assert!(schema.function.parameters.is_null());
1135    }
1136
1137    #[test]
1138    fn test_tool_roundtrip_preserves_additional_properties() {
1139        let tool = ToolSchema {
1140            schema_type: "function".to_string(),
1141            function: FunctionSchema {
1142                name: "edit".to_string(),
1143                description: "Edit a file".to_string(),
1144                parameters: serde_json::json!({
1145                    "type": "object",
1146                    "properties": {
1147                        "path": { "type": "string" },
1148                        "content": { "type": "string" }
1149                    },
1150                    "required": ["path"],
1151                    "additionalProperties": false
1152                }),
1153            },
1154        };
1155
1156        // Internal → Gemini
1157        let gemini: GeminiTool = tool.to_provider().unwrap();
1158
1159        // Gemini → Internal
1160        let roundtrip: ToolSchema = ToolSchema::from_provider(gemini).unwrap();
1161
1162        assert_eq!(roundtrip.function.name, "edit");
1163        assert_eq!(roundtrip.function.parameters["additionalProperties"], false);
1164        assert_eq!(
1165            roundtrip.function.parameters["required"],
1166            serde_json::json!(["path"])
1167        );
1168    }
1169
1170    #[test]
1171    fn test_empty_parts_has_default() {
1172        let internal = Message::assistant("", None);
1173
1174        let gemini: GeminiContent = internal.to_provider().unwrap();
1175
1176        // Should have at least one part with empty text
1177        assert_eq!(gemini.parts.len(), 1);
1178        assert_eq!(gemini.parts[0].text, Some(String::new()));
1179    }
1180}