dwara_core/ai/
openai_compat.rs

1//! The OpenAI-compatibility facade (DW-075).
2//!
3//! The CLIENT-facing dialect: every AI route accepts an OpenAI
4//! chat-completions request and answers in the OpenAI response shape,
5//! regardless of which provider actually served the call. This module
6//! owns that dialect's parse (`parse_chat_request`) and serialization
7//! (`response_to_openai`, `stream_event_to_openai_chunk`,
8//! `error_body`) — the inverse of [`crate::ai::adapters::openai`],
9//! which speaks the SAME dialect but toward the provider (where the
10//! model identifier is the mapped provider model, not the alias).
11//!
12//! Errors are emitted in an OpenAI-client-compatible shape
13//! (`{"error":{"message","type","code","request_id"}}`): the standard
14//! OpenAI SDKs parse `error.message/type/code`, and the extra
15//! `request_id` member carries the gateway's correlation ID without
16//! breaking those parsers.
17
18use crate::ai::adapter::AiError;
19use crate::ai::types::{
20    ChatMessage, ChatRequest, ChatResponse, ChatRole, ContentPart, FinishReason, ResponseFormat,
21    StreamEvent, ToolCall, ToolChoice, ToolSpec,
22};
23use serde_json::{json, Map, Value};
24use std::time::{SystemTime, UNIX_EPOCH};
25
26/// Parse an OpenAI chat-completions request body into the canonical
27/// [`ChatRequest`]. Unknown top-level parameters are preserved in
28/// `other` (the OpenAI provider adapter re-emits them; other dialects
29/// drop them — see the types docs).
30pub fn parse_chat_request(body: &Value) -> Result<ChatRequest, AiError> {
31    let obj = body
32        .as_object()
33        .ok_or_else(|| AiError::InvalidRequest("request body must be a JSON object".to_string()))?;
34    let model = obj
35        .get("model")
36        .and_then(Value::as_str)
37        .filter(|m| !m.is_empty())
38        .ok_or_else(|| AiError::InvalidRequest("'model' must be a non-empty string".to_string()))?
39        .to_string();
40    let messages_value = obj
41        .get("messages")
42        .and_then(Value::as_array)
43        .ok_or_else(|| AiError::InvalidRequest("'messages' must be an array".to_string()))?;
44    if messages_value.is_empty() {
45        return Err(AiError::InvalidRequest(
46            "'messages' must contain at least one message".to_string(),
47        ));
48    }
49    let mut messages = Vec::with_capacity(messages_value.len());
50    for (i, m) in messages_value.iter().enumerate() {
51        messages.push(
52            parse_message(m).map_err(|e| AiError::InvalidRequest(format!("messages[{i}]: {e}")))?,
53        );
54    }
55    let mut tools = Vec::new();
56    if let Some(arr) = obj.get("tools").and_then(Value::as_array) {
57        for t in arr {
58            tools.push(parse_tool(t)?);
59        }
60    }
61    let tool_choice = match obj.get("tool_choice") {
62        None | Some(Value::Null) => None,
63        Some(Value::String(s)) => match s.as_str() {
64            "auto" => Some(ToolChoice::Auto),
65            "none" => Some(ToolChoice::None),
66            "required" => Some(ToolChoice::Any),
67            other => {
68                return Err(AiError::InvalidRequest(format!(
69                    "tool_choice '{other}' is not one of auto|none|required"
70                )))
71            }
72        },
73        Some(v @ Value::Object(_)) => {
74            // {"type":"function","function":{"name":...}}
75            let name = v
76                .get("function")
77                .and_then(|f| f.get("name"))
78                .and_then(Value::as_str);
79            match (v.get("type").and_then(Value::as_str), name) {
80                (Some("function"), Some(n)) => Some(ToolChoice::Tool(n.to_string())),
81                (Some("function"), None) => {
82                    return Err(AiError::InvalidRequest(
83                        "tool_choice object form requires function.name".to_string(),
84                    ))
85                }
86                _ => None,
87            }
88        }
89        Some(_) => {
90            return Err(AiError::InvalidRequest(
91                "tool_choice must be a string or an object".to_string(),
92            ))
93        }
94    };
95    let stop = match obj.get("stop") {
96        None | Some(Value::Null) => None,
97        Some(Value::String(s)) => Some(vec![s.clone()]),
98        Some(Value::Array(a)) => Some(
99            a.iter()
100                .map(|v| {
101                    v.as_str().map(|s| s.to_string()).ok_or_else(|| {
102                        AiError::InvalidRequest("stop array entries must be strings".to_string())
103                    })
104                })
105                .collect::<Result<Vec<_>, _>>()?,
106        ),
107        Some(_) => {
108            return Err(AiError::InvalidRequest(
109                "stop must be a string or an array of strings".to_string(),
110            ))
111        }
112    };
113    let stream = obj.get("stream").and_then(Value::as_bool).unwrap_or(false);
114    let stream_options_include_usage = obj
115        .get("stream_options")
116        .and_then(|o| o.get("include_usage"))
117        .and_then(Value::as_bool)
118        .unwrap_or(false);
119    // AI-06 (#196): parse response_format into the typed field so
120    // adapters can translate it across dialects.
121    let response_format = parse_response_format(obj.get("response_format"));
122    // AI-14 (#202): parse the server-side prompt template reference
123    // and variables. The gateway resolves the template, substitutes
124    // variables, and prepends the result as a system message.
125    let prompt = obj
126        .get("prompt")
127        .and_then(Value::as_str)
128        .filter(|s| !s.is_empty())
129        .map(|s| s.to_string());
130    let prompt_variables = obj
131        .get("prompt_variables")
132        .and_then(Value::as_object)
133        .map(|m| {
134            m.iter()
135                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
136                .collect()
137        })
138        .unwrap_or_default();
139    // Preserve every parameter the canonical surface does not model so
140    // an OpenAI-to-OpenAI path is lossless.
141    const KNOWN: &[&str] = &[
142        "model",
143        "messages",
144        "tools",
145        "tool_choice",
146        "temperature",
147        "top_p",
148        "max_tokens",
149        "stop",
150        "stream",
151        "stream_options",
152        "response_format",  // AI-06: parsed into the typed field
153        "prompt",           // AI-14: parsed into the typed field
154        "prompt_variables", // AI-14: parsed into the typed field
155    ];
156    let other = obj
157        .iter()
158        .filter(|(k, _)| !KNOWN.contains(&k.as_str()))
159        .map(|(k, v)| (k.clone(), v.clone()))
160        .collect();
161    Ok(ChatRequest {
162        model,
163        messages,
164        tools,
165        tool_choice,
166        temperature: obj.get("temperature").and_then(Value::as_f64),
167        top_p: obj.get("top_p").and_then(Value::as_f64),
168        max_tokens: obj
169            .get("max_tokens")
170            .and_then(Value::as_u64)
171            .or_else(|| obj.get("max_completion_tokens").and_then(Value::as_u64)),
172        stop,
173        stream,
174        stream_options_include_usage,
175        response_format,
176        prompt,
177        prompt_variables,
178        other,
179    })
180}
181
182/// AI-06 (#196): Parse the OpenAI `response_format` field into the
183/// canonical `ResponseFormat` type. Returns `None` when absent or
184/// unparseable.
185fn parse_response_format(v: Option<&Value>) -> Option<ResponseFormat> {
186    let v = v?;
187    let obj = v.as_object()?;
188    let format_type = obj.get("type").and_then(Value::as_str)?;
189    match format_type {
190        "text" => Some(ResponseFormat::Text),
191        "json_object" => Some(ResponseFormat::JsonObject),
192        "json_schema" => {
193            let schema_obj = obj.get("json_schema")?.as_object()?;
194            let name = schema_obj.get("name")?.as_str()?.to_string();
195            let schema = schema_obj.get("schema")?.clone();
196            Some(ResponseFormat::JsonSchema { name, schema })
197        }
198        _ => None,
199    }
200}
201
202/// Parse one OpenAI message object.
203fn parse_message(v: &Value) -> Result<ChatMessage, String> {
204    let obj = v.as_object().ok_or("must be an object")?;
205    let role = match obj.get("role").and_then(Value::as_str) {
206        Some("system") => ChatRole::System,
207        Some("user") => ChatRole::User,
208        Some("assistant") => ChatRole::Assistant,
209        Some("tool") => ChatRole::Tool,
210        _ => return Err("role must be one of system|user|assistant|tool".into()),
211    };
212    let mut content = Vec::new();
213    match obj.get("content") {
214        None | Some(Value::Null) => {}
215        Some(Value::String(s)) => content.push(ContentPart::Text { text: s.clone() }),
216        Some(Value::Array(parts)) => {
217            for p in parts {
218                let pobj = p.as_object().ok_or("content parts must be objects")?;
219                match pobj.get("type").and_then(Value::as_str) {
220                    Some("text") => content.push(ContentPart::Text {
221                        text: pobj
222                            .get("text")
223                            .and_then(Value::as_str)
224                            .unwrap_or_default()
225                            .to_string(),
226                    }),
227                    Some("image_url") => {
228                        let url = pobj
229                            .get("image_url")
230                            .and_then(|i| i.get("url"))
231                            .and_then(Value::as_str)
232                            .unwrap_or_default();
233                        content.push(ContentPart::image_from_openai_url(url));
234                    }
235                    _ => return Err("content part type must be text or image_url".into()),
236                }
237            }
238        }
239        Some(_) => return Err("content must be a string or an array of parts".into()),
240    }
241    let mut tool_calls = Vec::new();
242    if let Some(arr) = obj.get("tool_calls").and_then(Value::as_array) {
243        for tc in arr {
244            let fn_obj = tc
245                .get("function")
246                .ok_or("tool_calls entries require function")?;
247            tool_calls.push(ToolCall {
248                id: tc
249                    .get("id")
250                    .and_then(Value::as_str)
251                    .unwrap_or_default()
252                    .to_string(),
253                name: fn_obj
254                    .get("name")
255                    .and_then(Value::as_str)
256                    .unwrap_or_default()
257                    .to_string(),
258                arguments: fn_obj
259                    .get("arguments")
260                    .and_then(Value::as_str)
261                    .unwrap_or_default()
262                    .to_string(),
263            });
264        }
265    }
266    Ok(ChatMessage {
267        role,
268        content,
269        name: obj.get("name").and_then(Value::as_str).map(String::from),
270        tool_calls,
271        tool_call_id: obj
272            .get("tool_call_id")
273            .and_then(Value::as_str)
274            .map(String::from),
275    })
276}
277
278/// Parse one OpenAI tool definition.
279fn parse_tool(v: &Value) -> Result<ToolSpec, AiError> {
280    let obj = v
281        .as_object()
282        .ok_or_else(|| AiError::InvalidRequest("tools entries must be objects".to_string()))?;
283    let func = obj.get("function").ok_or_else(|| {
284        AiError::InvalidRequest("tools entries require a function block".to_string())
285    })?;
286    Ok(ToolSpec {
287        name: func
288            .get("name")
289            .and_then(Value::as_str)
290            .unwrap_or_default()
291            .to_string(),
292        description: func
293            .get("description")
294            .and_then(Value::as_str)
295            .map(String::from),
296        parameters: func.get("parameters").cloned(),
297    })
298}
299
300/// Serialize a canonical [`ChatResponse`] into the OpenAI response
301/// shape. `model_alias` is echoed as the model (the provider's internal
302/// identifier never leaks); `request_id` rides along for correlation.
303pub fn response_to_openai(resp: &ChatResponse, model_alias: &str, request_id: &str) -> Value {
304    let choices: Vec<Value> = resp
305        .choices
306        .iter()
307        .map(|c| {
308            json!({
309                "index": c.index,
310                "message": message_to_openai(&c.message),
311                "finish_reason": finish_reason_to_openai(&c.finish_reason),
312            })
313        })
314        .collect();
315    let usage = resp.usage.map(|u| {
316        let mut obj = serde_json::Map::new();
317        obj.insert("prompt_tokens".into(), json!(u.prompt_tokens.unwrap_or(0)));
318        obj.insert(
319            "completion_tokens".into(),
320            json!(u.completion_tokens.unwrap_or(0)),
321        );
322        obj.insert("total_tokens".into(), json!(u.total_tokens.unwrap_or(0)));
323        if let Some(cached) = u.cached_tokens {
324            obj.insert(
325                "prompt_tokens_details".into(),
326                json!({"cached_tokens": cached}),
327            );
328        }
329        Value::Object(obj)
330    });
331    json!({
332        "id": resp.id.clone().unwrap_or_else(|| format!("chatcmpl-{request_id}")),
333        "object": "chat.completion",
334        "created": unix_now(),
335        "model": model_alias,
336        "choices": choices,
337        "usage": usage,
338    })
339}
340
341/// Serialize one canonical message (assistant-style) to the OpenAI
342/// shape.
343fn message_to_openai(m: &ChatMessage) -> Value {
344    let mut obj = Map::new();
345    obj.insert("role".into(), json!(m.role.as_str()));
346    if m.content
347        .iter()
348        .all(|p| matches!(p, ContentPart::Text { .. }))
349        && !m.content.is_empty()
350    {
351        obj.insert("content".into(), json!(m.text_content()));
352    } else if m.content.is_empty() {
353        obj.insert("content".into(), Value::Null);
354    } else {
355        let parts: Vec<Value> = m
356            .content
357            .iter()
358            .map(|p| match p {
359                ContentPart::Text { text } => json!({"type": "text", "text": text}),
360                ContentPart::Image { url, .. } => json!({
361                    "type": "image_url",
362                    "image_url": {"url": url.clone().unwrap_or_default()}
363                }),
364            })
365            .collect();
366        obj.insert("content".into(), Value::Array(parts));
367    }
368    if !m.tool_calls.is_empty() {
369        let calls: Vec<Value> = m
370            .tool_calls
371            .iter()
372            .map(|tc| {
373                json!({
374                    "id": tc.id,
375                    "type": "function",
376                    "function": {"name": tc.name, "arguments": tc.arguments}
377                })
378            })
379            .collect();
380        obj.insert("tool_calls".into(), Value::Array(calls));
381    }
382    if let Some(id) = &m.tool_call_id {
383        obj.insert("tool_call_id".into(), json!(id));
384    }
385    Value::Object(obj)
386}
387
388/// Map a canonical finish reason to the OpenAI token.
389fn finish_reason_to_openai(r: &FinishReason) -> Value {
390    match r {
391        FinishReason::Stop => json!("stop"),
392        FinishReason::Length => json!("length"),
393        FinishReason::ToolCalls => json!("tool_calls"),
394        FinishReason::ContentFilter => json!("content_filter"),
395        FinishReason::Other(_) => json!("stop"),
396    }
397}
398
399/// Serialize one canonical stream event as an OpenAI
400/// `chat.completion.chunk` (DW-077 wires the gateway SSE pipeline;
401/// shape verified here and in the adapter stream tests).
402pub fn stream_event_to_openai_chunk(
403    event: &StreamEvent,
404    id: &str,
405    model_alias: &str,
406    created: u64,
407) -> Value {
408    match event {
409        StreamEvent::Delta(d) => {
410            let mut delta = Map::new();
411            if let Some(role) = &d.role {
412                delta.insert("role".into(), json!(role.as_str()));
413            }
414            if let Some(content) = &d.content {
415                delta.insert("content".into(), json!(content));
416            }
417            if !d.tool_calls.is_empty() {
418                let calls: Vec<Value> = d
419                    .tool_calls
420                    .iter()
421                    .map(|tc| {
422                        let mut o = Map::new();
423                        o.insert("index".into(), json!(tc.index));
424                        if let Some(id) = &tc.id {
425                            o.insert("id".into(), json!(id));
426                        }
427                        let mut f = Map::new();
428                        if let Some(name) = &tc.name {
429                            f.insert("name".into(), json!(name));
430                        }
431                        f.insert("arguments".into(), json!(tc.arguments));
432                        o.insert("function".into(), Value::Object(f));
433                        Value::Object(o)
434                    })
435                    .collect();
436                delta.insert("tool_calls".into(), Value::Array(calls));
437            }
438            json!({
439                "id": id,
440                "object": "chat.completion.chunk",
441                "created": created,
442                "model": model_alias,
443                "choices": [{
444                    "index": d.index,
445                    "delta": Value::Object(delta),
446                    "finish_reason": d.finish_reason.as_ref().map(finish_reason_to_openai),
447                }]
448            })
449        }
450        StreamEvent::Usage(u) => {
451            let mut usage_obj = serde_json::Map::new();
452            usage_obj.insert("prompt_tokens".into(), json!(u.prompt_tokens.unwrap_or(0)));
453            usage_obj.insert(
454                "completion_tokens".into(),
455                json!(u.completion_tokens.unwrap_or(0)),
456            );
457            usage_obj.insert("total_tokens".into(), json!(u.total_tokens.unwrap_or(0)));
458            if let Some(cached) = u.cached_tokens {
459                usage_obj.insert(
460                    "prompt_tokens_details".into(),
461                    json!({"cached_tokens": cached}),
462                );
463            }
464            json!({
465                "id": id,
466                "object": "chat.completion.chunk",
467                "created": created,
468                "model": model_alias,
469                "choices": [],
470                "usage": Value::Object(usage_obj),
471            })
472        }
473        StreamEvent::Done => json!({
474            "id": id,
475            "object": "chat.completion.chunk",
476            "created": created,
477            "model": model_alias,
478            "choices": [{
479                "index": 0,
480                "delta": {},
481                "finish_reason": "stop",
482            }]
483        }),
484    }
485}
486
487/// Build the OpenAI-shaped error body every AI-route failure answers
488/// with. `error_type` follows the OpenAI taxonomy
489/// (`invalid_request_error`, `model_error`, `api_error`, ...).
490pub fn error_body(message: &str, error_type: &str, code: Option<&str>, request_id: &str) -> Value {
491    let mut err = Map::new();
492    err.insert("message".into(), json!(message));
493    err.insert("type".into(), json!(error_type));
494    if let Some(c) = code {
495        err.insert("code".into(), json!(c));
496    }
497    err.insert("request_id".into(), json!(request_id));
498    json!({ "error": Value::Object(err) })
499}
500
501/// The OpenAI error-type token for an [`AiError`], for pass-through
502/// error mapping.
503pub fn error_type_of(err: &AiError) -> &'static str {
504    match err {
505        AiError::InvalidRequest(_) => "invalid_request_error",
506        AiError::Translation(_) => "api_error",
507        AiError::Provider { .. } => "api_error",
508    }
509}
510
511/// Current Unix seconds (response `created` fields).
512pub fn unix_now() -> u64 {
513    SystemTime::now()
514        .duration_since(UNIX_EPOCH)
515        .map(|d| d.as_secs())
516        .unwrap_or(0)
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use crate::ai::types::Choice;
523
524    // White-box: facade parse edge cases that are cheaper to pin here
525    // than through full gateway round-trips (tests/ai_gateway.rs owns
526    // the end-to-end surface).
527
528    #[test]
529    fn parse_minimal_request() {
530        let req = parse_chat_request(&json!({
531            "model": "gpt-x",
532            "messages": [{"role": "user", "content": "hi"}]
533        }))
534        .unwrap();
535        assert_eq!(req.model, "gpt-x");
536        assert_eq!(req.messages.len(), 1);
537        assert!(!req.stream);
538        assert!(req.other.is_empty());
539    }
540
541    #[test]
542    fn parse_preserves_unknown_params() {
543        let req = parse_chat_request(&json!({
544            "model": "m",
545            "messages": [{"role": "user", "content": "hi"}],
546            "seed": 7,
547            "response_format": {"type": "json_object"}
548        }))
549        .unwrap();
550        assert_eq!(req.other.get("seed"), Some(&json!(7)));
551        // AI-06 (#196): response_format is now parsed into the typed
552        // field, not preserved in `other`.
553        assert!(matches!(
554            req.response_format,
555            Some(ResponseFormat::JsonObject)
556        ));
557        assert!(!req.other.contains_key("response_format"));
558    }
559
560    #[test]
561    fn parse_rejects_missing_model_and_empty_messages() {
562        assert!(matches!(
563            parse_chat_request(&json!({"messages": []})),
564            Err(AiError::InvalidRequest(_))
565        ));
566        assert!(matches!(
567            parse_chat_request(&json!({"model": "m", "messages": []})),
568            Err(AiError::InvalidRequest(_))
569        ));
570    }
571
572    #[test]
573    fn parse_tool_flow_roundtrip() {
574        let req = parse_chat_request(&json!({
575            "model": "m",
576            "messages": [
577                {"role": "user", "content": "weather?"},
578                {"role": "assistant", "content": null, "tool_calls": [
579                    {"id": "c1", "type": "function",
580                     "function": {"name": "get_weather", "arguments": "{\"city\":\"SF\"}"}}
581                ]},
582                {"role": "tool", "tool_call_id": "c1", "content": "72F"}
583            ],
584            "tools": [{"type": "function", "function": {
585                "name": "get_weather", "parameters": {"type": "object"}
586            }}],
587            "tool_choice": {"type": "function", "function": {"name": "get_weather"}}
588        }))
589        .unwrap();
590        assert_eq!(req.messages[1].tool_calls.len(), 1);
591        assert_eq!(req.messages[2].tool_call_id.as_deref(), Some("c1"));
592        assert_eq!(req.tools.len(), 1);
593        assert_eq!(
594            req.tool_choice,
595            Some(ToolChoice::Tool("get_weather".into()))
596        );
597        let resp = ChatResponse {
598            id: Some("r1".into()),
599            model: Some("provider-model".into()),
600            choices: vec![Choice {
601                index: 0,
602                message: ChatMessage::text(ChatRole::Assistant, "72F and sunny"),
603                finish_reason: FinishReason::Stop,
604            }],
605            usage: Some(crate::ai::types::Usage {
606                prompt_tokens: Some(10),
607                completion_tokens: Some(5),
608                total_tokens: Some(15),
609                cached_tokens: None,
610            }),
611        };
612        let out = response_to_openai(&resp, "alias-x", "req-1");
613        assert_eq!(out["model"], "alias-x");
614        assert_eq!(out["choices"][0]["message"]["content"], "72F and sunny");
615        assert_eq!(out["usage"]["total_tokens"], 15);
616    }
617}