dwara_core/ai/adapters/
gemini.rs

1//! The Google Gemini adapter (DW-075).
2//!
3//! Speaks the Gemini `generateContent` API
4//! (`POST /v1beta/models/{model}:generateContent` — note the model
5//! rides in the PATH, not the body, which is why
6//! [`ProviderAdapter::build_request`] takes the provider model
7//! explicitly). Translation notes:
8//!
9//! - System messages lift to `systemInstruction`.
10//! - Roles are `user`/`model` only; OpenAI `tool` results become user
11//!   turns carrying a `functionResponse` part. Gemini requires the
12//!   FUNCTION NAME there, while the OpenAI shape carries only the call
13//!   id — the adapter resolves the name by scanning backward through
14//!   the conversation for the assistant `tool_calls` entry with that
15//!   id, and falls back to the id itself when the history does not
16//!   contain it (a client that invented a tool_call_id out of thin
17//!   air).
18//! - Tool declarations become `functionDeclarations`; the JSON-Schema
19//!   `parameters` object is passed through minus `$schema` (which
20//!   Gemini rejects).
21//! - `data:` URI images become `inline_data` parts; remote image URLs
22//!   have no Gemini equivalent in this API and are DROPPED (documented
23//!   limitation of the translation, not a silent corruption — the
24//!   alternative, fetching the URL, is a side effect a gateway must
25//!   not invent).
26//! - Finish reasons map `STOP` -> stop, `MAX_TOKENS` -> length,
27//!   `SAFETY`/`PROHIBITED_CONTENT` -> content filter, `RECITATION` ->
28//!   other. Usage maps `promptTokenCount`/`candidatesTokenCount`.
29//! - Streaming uses `:streamGenerateContent?alt=sse`; each SSE data
30//!   payload is a `GenerateContentResponse` translated like a
31//!   single-choice fragment.
32
33use crate::ai::adapter::{AiError, ProviderAdapter, ProviderErrorBody, ProviderRequest};
34use crate::ai::types::{
35    ChatMessage, ChatRequest, ChatResponse, ChatRole, Choice, ContentPart, FinishReason,
36    StreamDelta, StreamEvent, ToolCall, Usage,
37};
38use crate::config::ai::AiProviderKind;
39use serde_json::{json, Map, Value};
40
41/// The Gemini generateContent adapter. Stateless singleton.
42pub struct GeminiAdapter;
43
44impl ProviderAdapter for GeminiAdapter {
45    fn kind(&self) -> AiProviderKind {
46        AiProviderKind::Gemini
47    }
48
49    fn build_request(
50        &self,
51        req: &ChatRequest,
52        provider_model: &str,
53    ) -> Result<ProviderRequest, AiError> {
54        let mut body = Map::new();
55        let system: Vec<String> = req
56            .messages
57            .iter()
58            .filter(|m| m.role == ChatRole::System)
59            .map(|m| m.text_content())
60            .filter(|s| !s.is_empty())
61            .collect();
62        if !system.is_empty() {
63            body.insert(
64                "systemInstruction".into(),
65                json!({"parts": [{"text": system.join("\n\n")}]}),
66            );
67        }
68        let mut contents: Vec<Value> = Vec::new();
69        for m in &req.messages {
70            match m.role {
71                ChatRole::System => continue,
72                ChatRole::User => contents.push(json!({
73                    "role": "user",
74                    "parts": user_parts(m),
75                })),
76                ChatRole::Assistant => {
77                    let mut parts: Vec<Value> = Vec::new();
78                    let mut text = String::new();
79                    for p in &m.content {
80                        if let ContentPart::Text { text: t } = p {
81                            text.push_str(t);
82                        }
83                    }
84                    if !text.is_empty() {
85                        parts.push(json!({"text": text}));
86                    }
87                    for tc in &m.tool_calls {
88                        parts.push(json!({
89                            "functionCall": {"name": tc.name, "args": tc.arguments_object()}
90                        }));
91                    }
92                    contents.push(json!({"role": "model", "parts": parts}));
93                }
94                ChatRole::Tool => {
95                    // Resolve the function NAME from the conversation
96                    // history (see module docs).
97                    let name = tool_name_for(&req.messages, m);
98                    // Gemini's functionResponse.response is an OBJECT
99                    // (proto Struct). Tool output that parses to an
100                    // object passes through; anything else — a bare
101                    // scalar, an array, a parse failure — is wrapped,
102                    // so legal OpenAI tool results (a calculator
103                    // returning `42`) cannot produce a provider 400.
104                    let response: Value = match m.text_content().trim().parse() {
105                        Ok(v @ Value::Object(_)) => v,
106                        Ok(other) => json!({"result": other}),
107                        Err(_) => json!({"result": m.text_content()}),
108                    };
109                    contents.push(json!({
110                        "role": "user",
111                        "parts": [{
112                            "functionResponse": {"name": name, "response": response}
113                        }],
114                    }));
115                }
116            }
117        }
118        if contents.is_empty() {
119            return Err(AiError::InvalidRequest(
120                "the conversation has no non-system messages".to_string(),
121            ));
122        }
123        body.insert("contents".into(), Value::Array(contents));
124        if !req.tools.is_empty() {
125            let decls: Vec<Value> = req
126                .tools
127                .iter()
128                .map(|t| {
129                    let mut o = Map::new();
130                    o.insert("name".into(), json!(t.name));
131                    if let Some(d) = &t.description {
132                        o.insert("description".into(), json!(d));
133                    }
134                    if let Some(p) = &t.parameters {
135                        // Gemini rejects $schema; everything else in
136                        // the OpenAPI-ish subset passes through.
137                        if let Some(pm) = p.as_object() {
138                            let filtered: Map<String, Value> = pm
139                                .iter()
140                                .filter(|(k, _)| k.as_str() != "$schema")
141                                .map(|(k, v)| (k.clone(), v.clone()))
142                                .collect();
143                            o.insert("parameters".into(), Value::Object(filtered));
144                        }
145                    }
146                    Value::Object(o)
147                })
148                .collect();
149            body.insert("tools".into(), json!([{"functionDeclarations": decls}]));
150            if let Some(choice) = &req.tool_choice {
151                let (mode, allowed) = match choice {
152                    crate::ai::types::ToolChoice::Auto => ("AUTO", None),
153                    crate::ai::types::ToolChoice::None => ("NONE", None),
154                    crate::ai::types::ToolChoice::Any => ("ANY", None),
155                    crate::ai::types::ToolChoice::Tool(name) => ("ANY", Some(vec![name.clone()])),
156                };
157                let mut tc = Map::new();
158                tc.insert("mode".into(), json!(mode));
159                if let Some(a) = allowed {
160                    tc.insert("allowed_function_names".into(), json!(a));
161                }
162                body.insert("toolConfig".into(), Value::Object(tc));
163            }
164        }
165        let mut generation = Map::new();
166        if let Some(t) = req.temperature {
167            generation.insert("temperature".into(), json!(t));
168        }
169        if let Some(p) = req.top_p {
170            generation.insert("topP".into(), json!(p));
171        }
172        if let Some(m) = req.max_tokens {
173            generation.insert("maxOutputTokens".into(), json!(m));
174        }
175        if let Some(stop) = &req.stop {
176            generation.insert("stopSequences".into(), json!(stop));
177        }
178        // AI-06 (#196): translate response_format to Gemini's
179        // responseMimeType and responseSchema in generationConfig.
180        if let Some(rf) = &req.response_format {
181            apply_gemini_response_format(&mut generation, rf);
182        }
183        if !generation.is_empty() {
184            body.insert("generationConfig".into(), Value::Object(generation));
185        }
186        let path = if req.stream {
187            format!("/v1beta/models/{provider_model}:streamGenerateContent?alt=sse")
188        } else {
189            format!("/v1beta/models/{provider_model}:generateContent")
190        };
191        Ok(ProviderRequest {
192            method: http::Method::POST,
193            path,
194            headers: vec![(http::header::CONTENT_TYPE, "application/json".to_string())],
195            body: Value::Object(body),
196        })
197    }
198
199    fn parse_response(&self, body: &Value) -> Result<ChatResponse, AiError> {
200        let obj = body.as_object().ok_or_else(|| {
201            AiError::Translation("gemini response is not a JSON object".to_string())
202        })?;
203        let candidates = obj
204            .get("candidates")
205            .and_then(Value::as_array)
206            .ok_or_else(|| AiError::Translation("gemini response has no candidates".to_string()))?;
207        let mut choices = Vec::with_capacity(candidates.len());
208        for (i, c) in candidates.iter().enumerate() {
209            let mut text = String::new();
210            let mut tool_calls = Vec::new();
211            if let Some(parts) = c
212                .get("content")
213                .and_then(|c| c.get("parts"))
214                .and_then(Value::as_array)
215            {
216                for p in parts {
217                    if let Some(t) = p.get("text").and_then(Value::as_str) {
218                        text.push_str(t);
219                    }
220                    if let Some(fc) = p.get("functionCall") {
221                        tool_calls.push(ToolCall {
222                            // Gemini function calls carry no id; a
223                            // stable synthetic one keeps the OpenAI
224                            // tool-call round trip (result by id)
225                            // working across this one response.
226                            id: format!("call-{i}-{}", tool_calls.len()),
227                            name: fc
228                                .get("name")
229                                .and_then(Value::as_str)
230                                .unwrap_or_default()
231                                .to_string(),
232                            arguments: fc.get("args").cloned().unwrap_or(json!({})).to_string(),
233                        });
234                    }
235                }
236            }
237            let finish_reason = match c.get("finishReason").and_then(Value::as_str) {
238                Some("STOP") | None => FinishReason::Stop,
239                Some("MAX_TOKENS") => FinishReason::Length,
240                Some("SAFETY") | Some("PROHIBITED_CONTENT") => FinishReason::ContentFilter,
241                Some(other) => FinishReason::Other(other.to_string()),
242            };
243            choices.push(Choice {
244                index: i as u64,
245                message: ChatMessage {
246                    role: ChatRole::Assistant,
247                    content: vec![ContentPart::Text { text }],
248                    name: None,
249                    tool_calls,
250                    tool_call_id: None,
251                },
252                finish_reason,
253            });
254        }
255        let usage = obj.get("usageMetadata").map(|u| Usage {
256            prompt_tokens: u.get("promptTokenCount").and_then(Value::as_u64),
257            completion_tokens: u.get("candidatesTokenCount").and_then(Value::as_u64),
258            total_tokens: u.get("totalTokenCount").and_then(Value::as_u64),
259            cached_tokens: u.get("cachedContentTokenCount").and_then(Value::as_u64),
260        });
261        Ok(ChatResponse {
262            id: obj
263                .get("responseId")
264                .and_then(Value::as_str)
265                .map(String::from),
266            model: None,
267            choices,
268            usage,
269        })
270    }
271
272    fn parse_error(&self, body: &Value) -> ProviderErrorBody {
273        // Gemini: {"error": {"code", "message", "status"}}.
274        let e = body.get("error").and_then(|e| e.as_object());
275        ProviderErrorBody {
276            message: e
277                .and_then(|e| e.get("message"))
278                .and_then(Value::as_str)
279                .unwrap_or("provider returned an error")
280                .to_string(),
281            error_type: e
282                .and_then(|e| e.get("status"))
283                .and_then(Value::as_str)
284                .map(String::from),
285            code: e.and_then(|e| e.get("code")).map(|c| match c {
286                Value::String(s) => s.clone(),
287                other => other.to_string(),
288            }),
289        }
290    }
291
292    fn parse_stream_event(&self, data: &Value) -> Result<Vec<StreamEvent>, AiError> {
293        // A streaming chunk is a GenerateContentResponse: candidates
294        // with parts fragments plus (on the final chunk) usageMetadata
295        // and a finishReason. A chunk with NO candidates (feedback-only
296        // chunks Gemini can emit) translates to zero events rather
297        // than an error — a stream must not die on a skippable frame.
298        let resp = match self.parse_response(data) {
299            Ok(r) => r,
300            Err(_) if data.get("candidates").is_none() => return Ok(Vec::new()),
301            Err(e) => return Err(e),
302        };
303        let mut out = Vec::new();
304        for choice in &resp.choices {
305            let mut content = None;
306            if !choice.message.content.is_empty() {
307                content = Some(choice.message.text_content());
308            }
309            if content.is_some() || !choice.message.tool_calls.is_empty() {
310                out.push(StreamEvent::Delta(StreamDelta {
311                    index: choice.index,
312                    role: None,
313                    content,
314                    tool_calls: choice
315                        .message
316                        .tool_calls
317                        .iter()
318                        .map(|tc| crate::ai::types::ToolCallDelta {
319                            index: choice.index,
320                            id: Some(tc.id.clone()),
321                            name: Some(tc.name.clone()),
322                            arguments: tc.arguments.clone(),
323                        })
324                        .collect(),
325                    finish_reason: None,
326                }));
327            }
328            if choice.finish_reason != FinishReason::Stop || resp.usage.is_some() {
329                out.push(StreamEvent::Delta(StreamDelta {
330                    index: choice.index,
331                    role: None,
332                    content: None,
333                    tool_calls: Vec::new(),
334                    finish_reason: Some(choice.finish_reason.clone()),
335                }));
336            }
337        }
338        if let Some(u) = resp.usage {
339            out.push(StreamEvent::Usage(u));
340        }
341        Ok(out)
342    }
343}
344
345/// User-turn parts: text and `data:` URI images (see module docs for
346/// the remote-URL limitation).
347fn user_parts(m: &ChatMessage) -> Value {
348    let mut parts: Vec<Value> = Vec::new();
349    let mut text = String::new();
350    for p in &m.content {
351        match p {
352            ContentPart::Text { text: t } => text.push_str(t),
353            ContentPart::Image {
354                url,
355                media_type,
356                data_b64,
357            } => {
358                if let Some(data) = data_b64 {
359                    parts.push(json!({
360                        "inline_data": {
361                            "mime_type": media_type.clone().unwrap_or_else(|| "image/png".into()),
362                            "data": data,
363                        }
364                    }));
365                } else if let Some(url) = url {
366                    if let Some((mime, data)) = crate::ai::types::split_data_uri(url) {
367                        parts.push(json!({
368                            "inline_data": {"mime_type": mime, "data": data}
369                        }));
370                    }
371                    // Remote image URLs are dropped (module docs).
372                }
373            }
374        }
375    }
376    if !text.is_empty() {
377        parts.insert(0, json!({"text": text}));
378    }
379    Value::Array(parts)
380}
381
382/// Find the function NAME a Tool message's tool_call_id refers to:
383/// scan backward for the assistant message carrying that call.
384fn tool_name_for(history: &[ChatMessage], tool_msg: &ChatMessage) -> String {
385    let id = tool_msg.tool_call_id.as_deref().unwrap_or("");
386    for m in history.iter().rev() {
387        if m.role == ChatRole::Assistant {
388            for tc in &m.tool_calls {
389                if tc.id == id {
390                    return tc.name.clone();
391                }
392            }
393        }
394    }
395    id.to_string()
396}
397
398/// AI-06 (#196): Translate the canonical `ResponseFormat` to Gemini's
399/// `generationConfig.responseMimeType` and `generationConfig.responseSchema`.
400/// Gemini uses `responseMimeType: "application/json"` for JSON output
401/// and `responseSchema` for the JSON Schema constraint.
402fn apply_gemini_response_format(
403    generation: &mut Map<String, Value>,
404    rf: &crate::ai::types::ResponseFormat,
405) {
406    use crate::ai::types::ResponseFormat;
407    match rf {
408        ResponseFormat::Text => { /* no-op; text is the default */ }
409        ResponseFormat::JsonObject => {
410            generation.insert("responseMimeType".into(), json!("application/json"));
411        }
412        ResponseFormat::JsonSchema { schema, .. } => {
413            generation.insert("responseMimeType".into(), json!("application/json"));
414            generation.insert("responseSchema".into(), schema.clone());
415        }
416    }
417}