dwara_core/ai/
ingress.rs

1//! AI-05 (#195): Multi-dialect ingress parsers.
2//!
3//! The gateway historically accepted only OpenAI-shaped chat requests
4//! (`openai_compat::parse_chat_request`). This module adds parsers for
5//! Anthropic Messages API and Gemini generateContent native request
6//! formats, translating them to the canonical `ChatRequest`. This lets
7//! clients using native Anthropic or Gemini SDKs use the gateway
8//! without switching to the OpenAI format.
9//!
10//! The parsers are the INVERSE of the provider adapters
11//! (`adapters/anthropic.rs`, `adapters/gemini.rs`): the adapter
12//! translates canonical -> provider-native for the outbound request;
13//! the ingress parser translates client-native -> canonical for the
14//! inbound request.
15
16use serde_json::Value;
17
18use crate::ai::types::{ChatMessage, ChatRequest, ChatRole, ContentPart, ToolChoice, ToolSpec};
19
20/// Parse an Anthropic Messages API request into the canonical
21/// `ChatRequest`. The Anthropic native format has:
22/// - `model`: string (required)
23/// - `messages`: array of {role, content} (required)
24/// - `system`: string or array of {type: "text", text} (optional, top-level)
25/// - `max_tokens`: int (required)
26/// - `temperature`, `top_p`: float (optional)
27/// - `stop_sequences`: array of strings (optional)
28/// - `stream`: bool (optional)
29/// - `tools`: array of {name, description, input_schema} (optional)
30/// - `tool_choice`: {type: "auto"|"any"|"tool", name: string} (optional)
31pub fn parse_anthropic_request(body: &Value) -> Result<ChatRequest, String> {
32    let obj = body
33        .as_object()
34        .ok_or("request body must be a JSON object")?;
35
36    let model = obj
37        .get("model")
38        .and_then(|v| v.as_str())
39        .ok_or("missing 'model' field")?
40        .to_string();
41
42    // System message (top-level, Anthropic convention).
43    let mut messages = Vec::new();
44    if let Some(system) = obj.get("system") {
45        let system_text = match system {
46            Value::String(s) => s.clone(),
47            Value::Array(parts) => parts
48                .iter()
49                .filter_map(|p| p.get("text").and_then(|t| t.as_str()))
50                .collect::<Vec<_>>()
51                .join("\n"),
52            _ => String::new(),
53        };
54        if !system_text.is_empty() {
55            messages.push(ChatMessage::text(ChatRole::System, system_text));
56        }
57    }
58
59    // Messages.
60    let raw_messages = obj
61        .get("messages")
62        .and_then(|v| v.as_array())
63        .ok_or("missing 'messages' field")?;
64    for msg in raw_messages {
65        let role_str = msg
66            .get("role")
67            .and_then(|v| v.as_str())
68            .ok_or("message missing 'role'")?;
69        let role = match role_str {
70            "user" => ChatRole::User,
71            "assistant" => ChatRole::Assistant,
72            other => return Err(format!("unknown role '{other}'")),
73        };
74        let content = parse_anthropic_content(msg.get("content"))?;
75        let tool_calls = parse_anthropic_tool_calls(msg.get("content"));
76        messages.push(ChatMessage {
77            role,
78            content,
79            name: None,
80            tool_calls,
81            tool_call_id: None,
82        });
83    }
84
85    if messages.is_empty() {
86        return Err("messages must be non-empty".to_string());
87    }
88
89    // Tools.
90    let tools = obj
91        .get("tools")
92        .and_then(|v| v.as_array())
93        .map(|tools| {
94            tools
95                .iter()
96                .filter_map(|t| {
97                    let name = t.get("name")?.as_str()?.to_string();
98                    let description = t
99                        .get("description")
100                        .and_then(|d| d.as_str())
101                        .map(String::from);
102                    let parameters = t.get("input_schema").cloned();
103                    Some(ToolSpec {
104                        name,
105                        description,
106                        parameters,
107                    })
108                })
109                .collect::<Vec<_>>()
110        })
111        .unwrap_or_default();
112
113    // Tool choice.
114    let tool_choice = obj.get("tool_choice").and_then(parse_anthropic_tool_choice);
115
116    // Other fields.
117    let temperature = obj.get("temperature").and_then(|v| v.as_f64());
118    let top_p = obj.get("top_p").and_then(|v| v.as_f64());
119    let max_tokens = obj.get("max_tokens").and_then(|v| v.as_u64());
120    let stop = obj
121        .get("stop_sequences")
122        .and_then(|v| v.as_array())
123        .map(|a| {
124            a.iter()
125                .filter_map(|v| v.as_str().map(String::from))
126                .collect()
127        });
128    let stream = obj.get("stream").and_then(|v| v.as_bool()).unwrap_or(false);
129
130    // Preserve unknown fields in `other`.
131    let known = [
132        "model",
133        "messages",
134        "system",
135        "max_tokens",
136        "temperature",
137        "top_p",
138        "stop_sequences",
139        "stream",
140        "tools",
141        "tool_choice",
142    ];
143    let other = obj
144        .iter()
145        .filter(|(k, _)| !known.contains(&k.as_str()))
146        .map(|(k, v)| (k.clone(), v.clone()))
147        .collect();
148
149    Ok(ChatRequest {
150        model,
151        messages,
152        tools,
153        tool_choice,
154        temperature,
155        top_p,
156        max_tokens,
157        stop,
158        stream,
159        stream_options_include_usage: false,
160        response_format: None,
161        prompt: None,
162        prompt_variables: Default::default(),
163        other,
164    })
165}
166
167/// Parse Anthropic content blocks into `ContentPart`s.
168fn parse_anthropic_content(content: Option<&Value>) -> Result<Vec<ContentPart>, String> {
169    match content {
170        None | Some(Value::Null) => Ok(Vec::new()),
171        Some(Value::String(s)) => Ok(vec![ContentPart::Text { text: s.clone() }]),
172        Some(Value::Array(parts)) => {
173            let mut result = Vec::new();
174            for part in parts {
175                let part_type = part.get("type").and_then(|t| t.as_str()).unwrap_or("text");
176                match part_type {
177                    "text" => {
178                        let text = part.get("text").and_then(|t| t.as_str()).unwrap_or("");
179                        result.push(ContentPart::Text {
180                            text: text.to_string(),
181                        });
182                    }
183                    "image" => {
184                        if let Some(source) = part.get("source") {
185                            let media_type = source
186                                .get("media_type")
187                                .and_then(|m| m.as_str())
188                                .map(String::from);
189                            let data_b64 = source
190                                .get("data")
191                                .and_then(|d| d.as_str())
192                                .map(String::from);
193                            let url = source.get("url").and_then(|u| u.as_str()).map(String::from);
194                            result.push(ContentPart::Image {
195                                url,
196                                media_type,
197                                data_b64,
198                            });
199                        }
200                    }
201                    "tool_use" => {
202                        // Tool calls are handled separately; skip here.
203                    }
204                    "tool_result" => {
205                        // Tool results are handled as text content.
206                        if let Some(content) = part.get("content") {
207                            let text = match content {
208                                Value::String(s) => s.clone(),
209                                Value::Array(parts) => parts
210                                    .iter()
211                                    .filter_map(|p| p.get("text").and_then(|t| t.as_str()))
212                                    .collect::<Vec<_>>()
213                                    .join(""),
214                                _ => String::new(),
215                            };
216                            result.push(ContentPart::Text { text });
217                        }
218                    }
219                    _ => {}
220                }
221            }
222            Ok(result)
223        }
224        _ => Err("content must be a string or array".to_string()),
225    }
226}
227
228/// Parse tool_use blocks from Anthropic content as tool calls.
229fn parse_anthropic_tool_calls(content: Option<&Value>) -> Vec<crate::ai::types::ToolCall> {
230    let mut calls = Vec::new();
231    if let Some(Value::Array(parts)) = content {
232        for part in parts {
233            if part.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
234                if let (Some(id), Some(name), Some(input)) = (
235                    part.get("id").and_then(|i| i.as_str()),
236                    part.get("name").and_then(|n| n.as_str()),
237                    part.get("input"),
238                ) {
239                    let arguments = serde_json::to_string(input).unwrap_or_default();
240                    calls.push(crate::ai::types::ToolCall {
241                        id: id.to_string(),
242                        name: name.to_string(),
243                        arguments,
244                    });
245                }
246            }
247        }
248    }
249    calls
250}
251
252/// Parse Anthropic tool_choice.
253fn parse_anthropic_tool_choice(v: &Value) -> Option<ToolChoice> {
254    let tc_type = v.get("type").and_then(|t| t.as_str())?;
255    match tc_type {
256        "auto" => Some(ToolChoice::Auto),
257        "any" => Some(ToolChoice::Any),
258        "tool" => v
259            .get("name")
260            .and_then(|n| n.as_str())
261            .map(|name| ToolChoice::Tool(name.to_string())),
262        _ => None,
263    }
264}
265
266/// Parse a Gemini generateContent request into the canonical
267/// `ChatRequest`. The Gemini native format has:
268/// - `contents`: array of {role, parts} (required)
269/// - `systemInstruction`: {parts: [{text}]} (optional)
270/// - `tools`: array of {functionDeclarations: [{name, description, parameters}]}
271/// - `toolConfig`: {mode: "AUTO"|"NONE"|"ANY", allowed_function_names}
272/// - `generationConfig`: {temperature, topP, maxOutputTokens, stopSequences}
273pub fn parse_gemini_request(body: &Value) -> Result<ChatRequest, String> {
274    let obj = body
275        .as_object()
276        .ok_or("request body must be a JSON object")?;
277
278    // System instruction.
279    let mut messages = Vec::new();
280    if let Some(sys) = obj.get("systemInstruction") {
281        let system_text = sys
282            .get("parts")
283            .and_then(|p| p.as_array())
284            .map(|parts| {
285                parts
286                    .iter()
287                    .filter_map(|p| p.get("text").and_then(|t| t.as_str()))
288                    .collect::<Vec<_>>()
289                    .join("\n")
290            })
291            .unwrap_or_default();
292        if !system_text.is_empty() {
293            messages.push(ChatMessage::text(ChatRole::System, system_text));
294        }
295    }
296
297    // Contents.
298    let contents = obj
299        .get("contents")
300        .and_then(|v| v.as_array())
301        .ok_or("missing 'contents' field")?;
302    for msg in contents {
303        let role_str = msg.get("role").and_then(|v| v.as_str()).unwrap_or("user");
304        let role = match role_str {
305            "user" => ChatRole::User,
306            "model" => ChatRole::Assistant,
307            "function" => ChatRole::Tool,
308            other => return Err(format!("unknown role '{other}'")),
309        };
310        let content = parse_gemini_parts(msg.get("parts"));
311        let tool_calls = parse_gemini_function_calls(msg.get("parts"));
312        messages.push(ChatMessage {
313            role,
314            content,
315            name: None,
316            tool_calls,
317            tool_call_id: None,
318        });
319    }
320
321    if messages.is_empty() {
322        return Err("contents must be non-empty".to_string());
323    }
324
325    // Tools.
326    let tools = obj
327        .get("tools")
328        .and_then(|v| v.as_array())
329        .and_then(|a| a.first())
330        .and_then(|t| t.get("functionDeclarations"))
331        .and_then(|f| f.as_array())
332        .map(|decls| {
333            decls
334                .iter()
335                .filter_map(|d| {
336                    let name = d.get("name")?.as_str()?.to_string();
337                    let description = d
338                        .get("description")
339                        .and_then(|d| d.as_str())
340                        .map(String::from);
341                    let parameters = d.get("parameters").cloned();
342                    Some(ToolSpec {
343                        name,
344                        description,
345                        parameters,
346                    })
347                })
348                .collect::<Vec<_>>()
349        })
350        .unwrap_or_default();
351
352    // Tool config.
353    let tool_choice = obj
354        .get("toolConfig")
355        .and_then(|tc| tc.get("mode"))
356        .and_then(|m| m.as_str())
357        .and_then(|mode| match mode {
358            "AUTO" => Some(ToolChoice::Auto),
359            "NONE" => Some(ToolChoice::None),
360            "ANY" => Some(ToolChoice::Any),
361            _ => None,
362        });
363
364    // Generation config.
365    let gen_cfg = obj.get("generationConfig");
366    let temperature = gen_cfg
367        .and_then(|g| g.get("temperature"))
368        .and_then(|v| v.as_f64());
369    let top_p = gen_cfg.and_then(|g| g.get("topP")).and_then(|v| v.as_f64());
370    let max_tokens = gen_cfg
371        .and_then(|g| g.get("maxOutputTokens"))
372        .and_then(|v| v.as_u64());
373    let stop = gen_cfg
374        .and_then(|g| g.get("stopSequences"))
375        .and_then(|v| v.as_array())
376        .map(|a| {
377            a.iter()
378                .filter_map(|v| v.as_str().map(String::from))
379                .collect()
380        });
381
382    // Preserve unknown fields.
383    let known = [
384        "contents",
385        "systemInstruction",
386        "tools",
387        "toolConfig",
388        "generationConfig",
389    ];
390    let other = obj
391        .iter()
392        .filter(|(k, _)| !known.contains(&k.as_str()))
393        .map(|(k, v)| (k.clone(), v.clone()))
394        .collect();
395
396    Ok(ChatRequest {
397        model: String::new(), // Gemini requests don't include the model in the body
398        messages,
399        tools,
400        tool_choice,
401        temperature,
402        top_p,
403        max_tokens,
404        stop,
405        stream: false,
406        stream_options_include_usage: false,
407        response_format: None,
408        prompt: None,
409        prompt_variables: Default::default(),
410        other,
411    })
412}
413
414/// Parse Gemini parts into ContentParts.
415fn parse_gemini_parts(parts: Option<&Value>) -> Vec<ContentPart> {
416    let mut result = Vec::new();
417    if let Some(Value::Array(parts)) = parts {
418        for part in parts {
419            if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
420                result.push(ContentPart::Text {
421                    text: text.to_string(),
422                });
423            } else if let Some(inline) = part.get("inlineData").or_else(|| part.get("inline_data"))
424            {
425                let media_type = inline
426                    .get("mimeType")
427                    .or_else(|| inline.get("mime_type"))
428                    .and_then(|m| m.as_str())
429                    .map(String::from);
430                let data_b64 = inline
431                    .get("data")
432                    .and_then(|d| d.as_str())
433                    .map(String::from);
434                result.push(ContentPart::Image {
435                    url: None,
436                    media_type,
437                    data_b64,
438                });
439            } else if let Some(file) = part.get("fileData").or_else(|| part.get("file_data")) {
440                let url = file
441                    .get("fileUri")
442                    .or_else(|| file.get("file_uri"))
443                    .and_then(|u| u.as_str())
444                    .map(String::from);
445                let media_type = file
446                    .get("mimeType")
447                    .or_else(|| file.get("mime_type"))
448                    .and_then(|m| m.as_str())
449                    .map(String::from);
450                result.push(ContentPart::Image {
451                    url,
452                    media_type,
453                    data_b64: None,
454                });
455            }
456        }
457    }
458    result
459}
460
461/// Parse Gemini functionCall parts as tool calls.
462fn parse_gemini_function_calls(parts: Option<&Value>) -> Vec<crate::ai::types::ToolCall> {
463    let mut calls = Vec::new();
464    if let Some(Value::Array(parts)) = parts {
465        for (i, part) in parts.iter().enumerate() {
466            if let Some(fc) = part.get("functionCall") {
467                let name = fc.get("name").and_then(|n| n.as_str()).unwrap_or("unknown");
468                let args = fc.get("args").cloned().unwrap_or(Value::Null);
469                let arguments = serde_json::to_string(&args).unwrap_or_default();
470                calls.push(crate::ai::types::ToolCall {
471                    id: format!("call-{i}-{name}"),
472                    name: name.to_string(),
473                    arguments,
474                });
475            }
476        }
477    }
478    calls
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use serde_json::json;
485
486    #[test]
487    fn parse_anthropic_simple_request() {
488        let body = json!({
489            "model": "claude-3-5-sonnet-20241022",
490            "max_tokens": 1024,
491            "messages": [
492                {"role": "user", "content": "Hello, world!"}
493            ]
494        });
495        let req = parse_anthropic_request(&body).unwrap();
496        assert_eq!(req.model, "claude-3-5-sonnet-20241022");
497        assert_eq!(req.messages.len(), 1);
498        assert_eq!(req.max_tokens, Some(1024));
499    }
500
501    #[test]
502    fn parse_anthropic_with_system() {
503        let body = json!({
504            "model": "claude-3-5-sonnet",
505            "max_tokens": 1024,
506            "system": "You are a helpful assistant.",
507            "messages": [
508                {"role": "user", "content": "Hi"}
509            ]
510        });
511        let req = parse_anthropic_request(&body).unwrap();
512        assert_eq!(req.messages.len(), 2);
513        assert_eq!(req.messages[0].role, ChatRole::System);
514    }
515
516    #[test]
517    fn parse_anthropic_with_tools() {
518        let body = json!({
519            "model": "claude-3-5-sonnet",
520            "max_tokens": 1024,
521            "messages": [{"role": "user", "content": "What's the weather?"}],
522            "tools": [{
523                "name": "get_weather",
524                "description": "Get weather",
525                "input_schema": {"type": "object", "properties": {}}
526            }],
527            "tool_choice": {"type": "any"}
528        });
529        let req = parse_anthropic_request(&body).unwrap();
530        assert_eq!(req.tools.len(), 1);
531        assert_eq!(req.tools[0].name, "get_weather");
532        assert_eq!(req.tool_choice, Some(ToolChoice::Any));
533    }
534
535    #[test]
536    fn parse_gemini_simple_request() {
537        let body = json!({
538            "contents": [
539                {"role": "user", "parts": [{"text": "Hello, world!"}]}
540            ]
541        });
542        let req = parse_gemini_request(&body).unwrap();
543        assert_eq!(req.messages.len(), 1);
544        assert_eq!(req.messages[0].role, ChatRole::User);
545    }
546
547    #[test]
548    fn parse_gemini_with_system_instruction() {
549        let body = json!({
550            "systemInstruction": {"parts": [{"text": "You are helpful."}]},
551            "contents": [
552                {"role": "user", "parts": [{"text": "Hi"}]}
553            ]
554        });
555        let req = parse_gemini_request(&body).unwrap();
556        assert_eq!(req.messages.len(), 2);
557        assert_eq!(req.messages[0].role, ChatRole::System);
558    }
559
560    #[test]
561    fn parse_gemini_with_tools() {
562        let body = json!({
563            "contents": [{"role": "user", "parts": [{"text": "Weather?"}]}],
564            "tools": [{
565                "functionDeclarations": [{
566                    "name": "get_weather",
567                    "description": "Get weather",
568                    "parameters": {"type": "object"}
569                }]
570            }],
571            "toolConfig": {"mode": "ANY"},
572            "generationConfig": {"temperature": 0.7, "maxOutputTokens": 1024}
573        });
574        let req = parse_gemini_request(&body).unwrap();
575        assert_eq!(req.tools.len(), 1);
576        assert_eq!(req.tools[0].name, "get_weather");
577        assert_eq!(req.tool_choice, Some(ToolChoice::Any));
578        assert_eq!(req.temperature, Some(0.7));
579        assert_eq!(req.max_tokens, Some(1024));
580    }
581}