Skip to main content

opendev_http/adapters/
gemini.rs

1//! Google Gemini adapter.
2//!
3//! Converts the internal Chat Completions format to the Gemini
4//! `generateContent` API and maps responses back.
5//!
6//! Key differences from OpenAI Chat Completions:
7//! - Uses `contents` array with `parts` instead of `messages`
8//! - System instruction is a separate top-level field
9//! - Tool calls use `functionCall` / `functionResponse`
10//! - Endpoint: `https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent`
11
12use serde_json::{Value, json};
13
14const DEFAULT_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta";
15
16/// Adapter for the Google Gemini `generateContent` API.
17#[derive(Debug, Clone)]
18pub struct GeminiAdapter {
19    base_url: String,
20    model: String,
21}
22
23impl GeminiAdapter {
24    /// Create a new Gemini adapter for the given model.
25    pub fn new(model: impl Into<String>) -> Self {
26        Self {
27            base_url: DEFAULT_BASE_URL.to_string(),
28            model: model.into(),
29        }
30    }
31
32    /// Create with a custom base URL (for proxies, etc.).
33    pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
34        self.base_url = url.into();
35        self
36    }
37
38    // ── Request conversion: Chat Completions → Gemini ──────────────────
39
40    /// Convert messages to Gemini `contents` array, extracting system instruction.
41    fn convert_messages(messages: &[Value]) -> (Option<String>, Vec<Value>) {
42        let mut system_text: Option<String> = None;
43        let mut contents: Vec<Value> = Vec::new();
44
45        for msg in messages {
46            let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
47            match role {
48                "system" => {
49                    let text = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
50                    system_text = Some(text.to_string());
51                }
52                "user" => {
53                    let parts = Self::content_to_parts(msg.get("content"));
54                    contents.push(json!({
55                        "role": "user",
56                        "parts": parts,
57                    }));
58                }
59                "assistant" => {
60                    let mut parts: Vec<Value> = Vec::new();
61
62                    // Text content
63                    if let Some(text) = msg.get("content").and_then(|c| c.as_str())
64                        && !text.is_empty()
65                    {
66                        parts.push(json!({"text": text}));
67                    }
68
69                    // Tool calls → functionCall parts
70                    if let Some(tool_calls) = msg.get("tool_calls").and_then(|tc| tc.as_array()) {
71                        for tc in tool_calls {
72                            let func = tc.get("function").cloned().unwrap_or(json!({}));
73                            let args_str = func
74                                .get("arguments")
75                                .and_then(|a| a.as_str())
76                                .unwrap_or("{}");
77                            let args: Value = serde_json::from_str(args_str).unwrap_or(json!({}));
78                            parts.push(json!({
79                                "functionCall": {
80                                    "name": func.get("name").and_then(|n| n.as_str()).unwrap_or(""),
81                                    "args": args,
82                                }
83                            }));
84                        }
85                    }
86
87                    if !parts.is_empty() {
88                        contents.push(json!({
89                            "role": "model",
90                            "parts": parts,
91                        }));
92                    }
93                }
94                "tool" => {
95                    // Tool results → functionResponse in a user turn
96                    let name = msg
97                        .get("name")
98                        .and_then(|n| n.as_str())
99                        .unwrap_or("function");
100                    let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
101                    // Try to parse content as JSON for structured response
102                    let response_val: Value = serde_json::from_str(content)
103                        .unwrap_or_else(|_| json!({"result": content}));
104
105                    contents.push(json!({
106                        "role": "user",
107                        "parts": [{
108                            "functionResponse": {
109                                "name": name,
110                                "response": response_val,
111                            }
112                        }]
113                    }));
114                }
115                _ => {}
116            }
117        }
118
119        (system_text, contents)
120    }
121
122    /// Convert a content value (string or array of blocks) to Gemini parts.
123    fn content_to_parts(content: Option<&Value>) -> Vec<Value> {
124        match content {
125            Some(Value::String(s)) => vec![json!({"text": s})],
126            Some(Value::Array(blocks)) => {
127                blocks
128                    .iter()
129                    .filter_map(|block| {
130                        let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
131                        match block_type {
132                            "text" => {
133                                let text = block.get("text").and_then(|t| t.as_str()).unwrap_or("");
134                                Some(json!({"text": text}))
135                            }
136                            "image_url" => {
137                                // data:mime;base64,data → inlineData
138                                if let Some(url) = block
139                                    .get("image_url")
140                                    .and_then(|iu| iu.get("url"))
141                                    .and_then(|u| u.as_str())
142                                    && let Some(rest) = url.strip_prefix("data:")
143                                    && let Some((mime, data)) = rest.split_once(";base64,")
144                                {
145                                    return Some(json!({
146                                        "inlineData": {
147                                            "mimeType": mime,
148                                            "data": data,
149                                        }
150                                    }));
151                                }
152                                None
153                            }
154                            _ => Some(json!({"text": block.to_string()})),
155                        }
156                    })
157                    .collect()
158            }
159            _ => vec![json!({"text": ""})],
160        }
161    }
162
163    /// Convert Chat Completions tool definitions to Gemini function declarations.
164    fn convert_tools(tools: &[Value]) -> Vec<Value> {
165        tools
166            .iter()
167            .filter_map(|tool| {
168                let func = tool.get("function")?;
169                Some(json!({
170                    "name": func.get("name").and_then(|n| n.as_str()).unwrap_or(""),
171                    "description": func.get("description").and_then(|d| d.as_str()).unwrap_or(""),
172                    "parameters": func.get("parameters").cloned().unwrap_or(json!({"type": "object", "properties": {}})),
173                }))
174            })
175            .collect()
176    }
177
178    // ── Response conversion: Gemini → Chat Completions ─────────────────
179
180    /// Convert a Gemini generateContent response to Chat Completions format.
181    fn response_to_chat_completions(&self, response: &Value) -> Value {
182        let candidates = response
183            .get("candidates")
184            .and_then(|c| c.as_array())
185            .cloned()
186            .unwrap_or_default();
187
188        let candidate = candidates.first().cloned().unwrap_or(json!({}));
189        let parts = candidate
190            .get("content")
191            .and_then(|c| c.get("parts"))
192            .and_then(|p| p.as_array())
193            .cloned()
194            .unwrap_or_default();
195
196        // Extract text parts (skip parts that are thought-only)
197        let text_parts: Vec<String> = parts
198            .iter()
199            .filter_map(|p| {
200                // Skip parts that have thought=true (Gemini thinking)
201                if p.get("thought").and_then(|t| t.as_bool()) == Some(true) {
202                    return None;
203                }
204                p.get("text").and_then(|t| t.as_str()).map(String::from)
205            })
206            .collect();
207
208        // Extract thinking/reasoning content from thought parts
209        let thinking_parts: Vec<String> = parts
210            .iter()
211            .filter_map(|p| {
212                if p.get("thought").and_then(|t| t.as_bool()) == Some(true) {
213                    p.get("text").and_then(|t| t.as_str()).map(String::from)
214                } else {
215                    None
216                }
217            })
218            .collect();
219        let reasoning_content = if thinking_parts.is_empty() {
220            None
221        } else {
222            Some(thinking_parts.join("\n\n"))
223        };
224
225        // Extract function calls
226        let tool_calls: Vec<Value> = parts
227            .iter()
228            .enumerate()
229            .filter_map(|(i, p)| {
230                let fc = p.get("functionCall")?;
231                let name = fc.get("name").and_then(|n| n.as_str()).unwrap_or("");
232                let args = fc.get("args").cloned().unwrap_or(json!({}));
233                Some(json!({
234                    "id": format!("call_{i}"),
235                    "type": "function",
236                    "function": {
237                        "name": name,
238                        "arguments": serde_json::to_string(&args).unwrap_or_default(),
239                    }
240                }))
241            })
242            .collect();
243
244        let content = if text_parts.is_empty() {
245            Value::Null
246        } else {
247            Value::String(text_parts.join(""))
248        };
249
250        let finish_reason_raw = candidate
251            .get("finishReason")
252            .and_then(|r| r.as_str())
253            .unwrap_or("STOP");
254
255        let finish_reason = match finish_reason_raw {
256            "STOP" => {
257                if tool_calls.is_empty() {
258                    "stop"
259                } else {
260                    "tool_calls"
261                }
262            }
263            "MAX_TOKENS" => "length",
264            "SAFETY" => "content_filter",
265            _ => "stop",
266        };
267
268        let mut message = json!({
269            "role": "assistant",
270            "content": content,
271        });
272
273        if !tool_calls.is_empty() {
274            message["tool_calls"] = Value::Array(tool_calls);
275        }
276        if let Some(ref reasoning) = reasoning_content {
277            message["reasoning_content"] = Value::String(reasoning.clone());
278        }
279
280        // Usage
281        let usage_meta = response.get("usageMetadata").cloned().unwrap_or(json!({}));
282        let prompt_tokens = usage_meta
283            .get("promptTokenCount")
284            .and_then(|t| t.as_u64())
285            .unwrap_or(0);
286        let completion_tokens = usage_meta
287            .get("candidatesTokenCount")
288            .and_then(|t| t.as_u64())
289            .unwrap_or(0);
290
291        json!({
292            "id": format!("gemini-{}", uuid::Uuid::new_v4()),
293            "object": "chat.completion",
294            "model": &self.model,
295            "choices": [{
296                "index": 0,
297                "message": message,
298                "finish_reason": finish_reason,
299            }],
300            "usage": {
301                "prompt_tokens": prompt_tokens,
302                "completion_tokens": completion_tokens,
303                "total_tokens": prompt_tokens + completion_tokens,
304            },
305        })
306    }
307
308    /// Check if the model supports native thinking (Gemini 2.5+).
309    fn supports_thinking(model: &str) -> bool {
310        model.contains("2.5") || model.contains("2-5")
311    }
312
313    /// Map reasoning effort level to a thinking budget token count.
314    fn thinking_budget(effort: &str) -> u64 {
315        match effort {
316            "low" => 4000,
317            "high" => 24576,
318            _ => 16000, // medium
319        }
320    }
321}
322
323impl Default for GeminiAdapter {
324    fn default() -> Self {
325        Self::new("gemini-2.0-flash")
326    }
327}
328
329#[async_trait::async_trait]
330impl super::base::ProviderAdapter for GeminiAdapter {
331    fn provider_name(&self) -> &str {
332        "gemini"
333    }
334
335    fn convert_request(&self, payload: Value) -> Value {
336        let mut payload = payload;
337
338        // Extract and remove internal reasoning effort field
339        let reasoning_effort = payload
340            .as_object_mut()
341            .and_then(|obj| obj.remove("_reasoning_effort"))
342            .and_then(|v| v.as_str().map(String::from));
343
344        let messages = payload
345            .get("messages")
346            .and_then(|m| m.as_array())
347            .cloned()
348            .unwrap_or_default();
349
350        let (system_instruction, contents) = Self::convert_messages(&messages);
351
352        let mut gemini_payload = json!({
353            "contents": contents,
354        });
355
356        if let Some(system) = system_instruction {
357            gemini_payload["systemInstruction"] = json!({
358                "parts": [{"text": system}]
359            });
360        }
361
362        // Generation config
363        let mut gen_config = json!({});
364        if let Some(temp) = payload.get("temperature") {
365            gen_config["temperature"] = temp.clone();
366        }
367        if let Some(top_p) = payload.get("top_p") {
368            gen_config["topP"] = top_p.clone();
369        }
370        let max_tok = payload
371            .get("max_tokens")
372            .or_else(|| payload.get("max_completion_tokens"));
373        if let Some(tok) = max_tok {
374            gen_config["maxOutputTokens"] = tok.clone();
375        }
376
377        // Thinking config for Gemini 2.5+ models
378        if Self::supports_thinking(&self.model)
379            && let Some(ref effort) = reasoning_effort
380        {
381            gen_config["thinkingConfig"] = json!({
382                "includeThoughts": true,
383                "thinkingBudget": Self::thinking_budget(effort),
384            });
385        }
386
387        if gen_config.as_object().is_some_and(|o| !o.is_empty()) {
388            gemini_payload["generationConfig"] = gen_config;
389        }
390
391        // Tools
392        if let Some(tools) = payload.get("tools").and_then(|t| t.as_array()) {
393            let declarations = Self::convert_tools(tools);
394            if !declarations.is_empty() {
395                gemini_payload["tools"] = json!([{
396                    "functionDeclarations": declarations,
397                }]);
398            }
399        }
400
401        gemini_payload
402    }
403
404    fn convert_response(&self, response: Value) -> Value {
405        self.response_to_chat_completions(&response)
406    }
407
408    fn api_url(&self) -> &str {
409        &self.base_url
410    }
411
412    fn supports_streaming(&self) -> bool {
413        true
414    }
415
416    fn enable_streaming(&self, _payload: &mut Value) {
417        // Gemini doesn't use a payload flag — streaming is via URL endpoint
418    }
419
420    fn streaming_url(&self, base_url: &str) -> Option<String> {
421        // Transform generateContent → streamGenerateContent?alt=sse
422        Some(base_url.replace(":generateContent", ":streamGenerateContent?alt=sse"))
423    }
424
425    fn parse_stream_event(
426        &self,
427        _event_type: &str,
428        data: &Value,
429    ) -> Option<crate::streaming::StreamEvent> {
430        use crate::streaming::StreamEvent;
431
432        // Gemini streams partial generateContent responses as data: lines.
433        // Each chunk has candidates[0].content.parts with text or thought parts.
434        let candidates = data.get("candidates")?.as_array()?;
435        let candidate = candidates.first()?;
436        let parts = candidate.get("content")?.get("parts")?.as_array()?;
437
438        for part in parts {
439            let is_thought = part.get("thought").and_then(|t| t.as_bool()) == Some(true);
440            if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
441                if is_thought {
442                    return Some(StreamEvent::ReasoningDelta(text.to_string()));
443                } else {
444                    return Some(StreamEvent::TextDelta(text.to_string()));
445                }
446            }
447        }
448
449        // Check for error
450        if let Some(error) = data.get("error") {
451            let msg = error
452                .get("message")
453                .and_then(|m| m.as_str())
454                .unwrap_or("Unknown Gemini error");
455            return Some(StreamEvent::Error(msg.to_string()));
456        }
457
458        None
459    }
460}
461
462/// Build the full Gemini API URL for a given model.
463pub fn gemini_api_url(base_url: &str, model: &str) -> String {
464    format!("{base_url}/models/{model}:generateContent")
465}
466
467#[cfg(test)]
468#[path = "gemini_tests.rs"]
469mod tests;