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