dwara_core/ai/adapters/
openai.rs

1//! The OpenAI provider adapter (DW-075).
2//!
3//! Speaks the OpenAI chat-completions dialect TOWARD a provider
4//! (`POST /v1/chat/completions`). Because the canonical request type is
5//! a superset of this dialect, the translation is near-identity: the
6//! adapter's job is to swap the client's model alias for the mapped
7//! provider model, re-emit the canonical fields in OpenAI spelling,
8//! and pass the preserved dialect-specific parameters (`other`)
9//! through — the lossless OpenAI-to-OpenAI path. The same dialect is
10//! spoken by OpenAI-COMPATIBLE servers (vLLM, Ollama's compatibility
11//! endpoint, and others): point the provider's upstream at one of
12//! those and this adapter needs no changes.
13
14use crate::ai::adapter::{AiError, ProviderAdapter, ProviderErrorBody, ProviderRequest};
15use crate::ai::types::{
16    ChatMessage, ChatRequest, ChatResponse, ChatRole, Choice, ContentPart, FinishReason,
17    StreamDelta, StreamEvent, ToolCallDelta, Usage,
18};
19use crate::config::ai::AiProviderKind;
20use serde_json::{json, Map, Value};
21
22/// The OpenAI chat-completions adapter. Stateless singleton.
23pub struct OpenAiAdapter;
24
25impl ProviderAdapter for OpenAiAdapter {
26    fn kind(&self) -> AiProviderKind {
27        AiProviderKind::Openai
28    }
29
30    fn build_request(
31        &self,
32        req: &ChatRequest,
33        provider_model: &str,
34    ) -> Result<ProviderRequest, AiError> {
35        let mut body = Map::new();
36        body.insert("model".into(), json!(provider_model));
37        let messages: Vec<Value> = req.messages.iter().map(message_to_openai).collect();
38        body.insert("messages".into(), Value::Array(messages));
39        if !req.tools.is_empty() {
40            let tools: Vec<Value> = req
41                .tools
42                .iter()
43                .map(|t| {
44                    let mut f = Map::new();
45                    f.insert("name".into(), json!(t.name));
46                    if let Some(d) = &t.description {
47                        f.insert("description".into(), json!(d));
48                    }
49                    if let Some(p) = &t.parameters {
50                        f.insert("parameters".into(), p.clone());
51                    }
52                    json!({"type": "function", "function": Value::Object(f)})
53                })
54                .collect();
55            body.insert("tools".into(), Value::Array(tools));
56            if let Some(choice) = &req.tool_choice {
57                body.insert(
58                    "tool_choice".into(),
59                    match choice {
60                        crate::ai::types::ToolChoice::Auto => json!("auto"),
61                        crate::ai::types::ToolChoice::None => json!("none"),
62                        crate::ai::types::ToolChoice::Any => json!("required"),
63                        crate::ai::types::ToolChoice::Tool(name) => json!({
64                            "type": "function",
65                            "function": {"name": name}
66                        }),
67                    },
68                );
69            }
70        }
71        if let Some(t) = req.temperature {
72            body.insert("temperature".into(), json!(t));
73        }
74        if let Some(p) = req.top_p {
75            body.insert("top_p".into(), json!(p));
76        }
77        if let Some(m) = req.max_tokens {
78            body.insert("max_tokens".into(), json!(m));
79        }
80        if let Some(stop) = &req.stop {
81            body.insert("stop".into(), json!(stop));
82        }
83        if req.stream {
84            body.insert("stream".into(), json!(true));
85            if req.stream_options_include_usage {
86                body.insert("stream_options".into(), json!({"include_usage": true}));
87            }
88        }
89        // AI-06 (#196): serialize the typed response_format back to
90        // the OpenAI wire shape.
91        if let Some(rf) = &req.response_format {
92            body.insert("response_format".into(), response_format_to_openai(rf));
93        }
94        // Dialect-specific parameters pass through verbatim (the
95        // canonical surface preserved them for exactly this adapter).
96        for (k, v) in &req.other {
97            body.insert(k.clone(), v.clone());
98        }
99        Ok(ProviderRequest {
100            method: http::Method::POST,
101            path: "/v1/chat/completions".to_string(),
102            headers: vec![],
103            body: Value::Object(body),
104        })
105    }
106
107    fn parse_response(&self, body: &Value) -> Result<ChatResponse, AiError> {
108        let obj = body.as_object().ok_or_else(|| {
109            AiError::Translation("openai response is not a JSON object".to_string())
110        })?;
111        let choices_arr = obj
112            .get("choices")
113            .and_then(Value::as_array)
114            .ok_or_else(|| AiError::Translation("openai response has no choices".to_string()))?;
115        let mut choices = Vec::with_capacity(choices_arr.len());
116        for c in choices_arr {
117            let index = c.get("index").and_then(Value::as_u64).unwrap_or(0);
118            let message = c
119                .get("message")
120                .ok_or_else(|| AiError::Translation("openai choice has no message".to_string()))?;
121            choices.push(Choice {
122                index,
123                message: parse_openai_message(message)?,
124                finish_reason: parse_finish_reason(c.get("finish_reason")),
125            });
126        }
127        let usage = obj.get("usage").map(parse_usage);
128        Ok(ChatResponse {
129            id: obj.get("id").and_then(Value::as_str).map(String::from),
130            model: obj.get("model").and_then(Value::as_str).map(String::from),
131            choices,
132            usage,
133        })
134    }
135
136    fn parse_error(&self, body: &Value) -> ProviderErrorBody {
137        // OpenAI: {"error": {"message", "type", "code"}} (or a bare
138        // message string under error).
139        match body.get("error") {
140            Some(Value::Object(e)) => ProviderErrorBody {
141                message: e
142                    .get("message")
143                    .and_then(Value::as_str)
144                    .unwrap_or("provider returned an error")
145                    .to_string(),
146                error_type: e.get("type").and_then(Value::as_str).map(String::from),
147                code: e.get("code").map(|c| match c {
148                    Value::String(s) => s.clone(),
149                    other => other.to_string(),
150                }),
151            },
152            Some(Value::String(s)) => ProviderErrorBody {
153                message: s.clone(),
154                error_type: None,
155                code: None,
156            },
157            _ => ProviderErrorBody {
158                message: "provider returned an error".to_string(),
159                error_type: None,
160                code: None,
161            },
162        }
163    }
164
165    fn parse_stream_event(&self, data: &Value) -> Result<Vec<StreamEvent>, AiError> {
166        let obj = data.as_object().ok_or_else(|| {
167            AiError::Translation("openai stream chunk is not a JSON object".to_string())
168        })?;
169        let mut out = Vec::new();
170        if let Some(choices) = obj.get("choices").and_then(Value::as_array) {
171            for c in choices {
172                let index = c.get("index").and_then(Value::as_u64).unwrap_or(0);
173                let delta = c.get("delta").cloned().unwrap_or(Value::Null);
174                let mut sd = StreamDelta {
175                    index,
176                    role: None,
177                    content: None,
178                    tool_calls: Vec::new(),
179                    finish_reason: None,
180                };
181                if let Some(role) = delta.get("role").and_then(Value::as_str) {
182                    sd.role = match role {
183                        "assistant" => Some(ChatRole::Assistant),
184                        _ => None,
185                    };
186                }
187                if let Some(text) = delta.get("content").and_then(Value::as_str) {
188                    sd.content = Some(text.to_string());
189                }
190                if let Some(calls) = delta.get("tool_calls").and_then(Value::as_array) {
191                    for tc in calls {
192                        sd.tool_calls.push(ToolCallDelta {
193                            index: tc.get("index").and_then(Value::as_u64).unwrap_or(0),
194                            id: tc.get("id").and_then(Value::as_str).map(String::from),
195                            name: tc
196                                .get("function")
197                                .and_then(|f| f.get("name"))
198                                .and_then(Value::as_str)
199                                .map(String::from),
200                            arguments: tc
201                                .get("function")
202                                .and_then(|f| f.get("arguments"))
203                                .and_then(Value::as_str)
204                                .unwrap_or_default()
205                                .to_string(),
206                        });
207                    }
208                }
209                sd.finish_reason = c
210                    .get("finish_reason")
211                    .filter(|v| !v.is_null())
212                    .map(|v| parse_finish_reason(Some(v)));
213                out.push(StreamEvent::Delta(sd));
214            }
215        }
216        if let Some(usage) = obj.get("usage").filter(|u| !u.is_null()) {
217            out.push(StreamEvent::Usage(parse_usage(usage)));
218        }
219        Ok(out)
220    }
221
222    fn stream_done_sentinel(&self) -> Option<&'static str> {
223        Some("[DONE]")
224    }
225}
226
227/// Parse one OpenAI wire message into the canonical shape.
228pub(crate) fn parse_openai_message(v: &Value) -> Result<ChatMessage, AiError> {
229    let obj = v
230        .as_object()
231        .ok_or_else(|| AiError::Translation("openai message is not an object".to_string()))?;
232    let role = match obj.get("role").and_then(Value::as_str) {
233        Some("system") => ChatRole::System,
234        Some("user") => ChatRole::User,
235        Some("assistant") => ChatRole::Assistant,
236        Some("tool") => ChatRole::Tool,
237        _ => ChatRole::Assistant,
238    };
239    let mut content = Vec::new();
240    match obj.get("content") {
241        Some(Value::String(s)) => content.push(ContentPart::Text { text: s.clone() }),
242        Some(Value::Array(parts)) => {
243            for p in parts {
244                match p.get("type").and_then(Value::as_str) {
245                    Some("text") => content.push(ContentPart::Text {
246                        text: p
247                            .get("text")
248                            .and_then(Value::as_str)
249                            .unwrap_or_default()
250                            .to_string(),
251                    }),
252                    Some("image_url") => content.push(ContentPart::Image {
253                        url: p
254                            .get("image_url")
255                            .and_then(|i| i.get("url"))
256                            .and_then(Value::as_str)
257                            .map(String::from),
258                        media_type: None,
259                        data_b64: None,
260                    }),
261                    _ => {}
262                }
263            }
264        }
265        _ => {}
266    }
267    let mut tool_calls = Vec::new();
268    if let Some(calls) = obj.get("tool_calls").and_then(Value::as_array) {
269        for tc in calls {
270            let func = tc.get("function").cloned().unwrap_or(Value::Null);
271            tool_calls.push(crate::ai::types::ToolCall {
272                id: tc
273                    .get("id")
274                    .and_then(Value::as_str)
275                    .unwrap_or_default()
276                    .to_string(),
277                name: func
278                    .get("name")
279                    .and_then(Value::as_str)
280                    .unwrap_or_default()
281                    .to_string(),
282                arguments: func
283                    .get("arguments")
284                    .and_then(Value::as_str)
285                    .unwrap_or_default()
286                    .to_string(),
287            });
288        }
289    }
290    Ok(ChatMessage {
291        role,
292        content,
293        name: obj.get("name").and_then(Value::as_str).map(String::from),
294        tool_calls,
295        tool_call_id: obj
296            .get("tool_call_id")
297            .and_then(Value::as_str)
298            .map(String::from),
299    })
300}
301
302/// Serialize a canonical message to the OpenAI provider dialect
303/// (shared shape with the facade's `message_to_openai`, kept separate
304/// because the provider path must NOT invent fields the client never
305/// sent).
306pub(crate) fn message_to_openai(m: &ChatMessage) -> Value {
307    let mut obj = Map::new();
308    obj.insert("role".into(), json!(m.role.as_str()));
309    if m.content.len() == 1 {
310        if let ContentPart::Text { text } = &m.content[0] {
311            obj.insert("content".into(), json!(text));
312        }
313    } else if !m.content.is_empty() {
314        let parts: Vec<Value> = m
315            .content
316            .iter()
317            .map(|p| match p {
318                ContentPart::Text { text } => json!({"type": "text", "text": text}),
319                ContentPart::Image { url, .. } => json!({
320                    "type": "image_url",
321                    "image_url": {"url": url.clone().unwrap_or_default()}
322                }),
323            })
324            .collect();
325        obj.insert("content".into(), Value::Array(parts));
326    } else {
327        obj.insert("content".into(), Value::Null);
328    }
329    if let Some(name) = &m.name {
330        obj.insert("name".into(), json!(name));
331    }
332    if !m.tool_calls.is_empty() {
333        let calls: Vec<Value> = m
334            .tool_calls
335            .iter()
336            .map(|tc| {
337                json!({
338                    "id": tc.id,
339                    "type": "function",
340                    "function": {"name": tc.name, "arguments": tc.arguments}
341                })
342            })
343            .collect();
344        obj.insert("tool_calls".into(), Value::Array(calls));
345    }
346    if let Some(id) = &m.tool_call_id {
347        obj.insert("tool_call_id".into(), json!(id));
348    }
349    Value::Object(obj)
350}
351
352/// Parse an OpenAI finish_reason (`null` mid-stream).
353fn parse_finish_reason(v: Option<&Value>) -> FinishReason {
354    match v.and_then(Value::as_str) {
355        Some("stop") | None => FinishReason::Stop,
356        Some("length") => FinishReason::Length,
357        Some("tool_calls") | Some("function_call") => FinishReason::ToolCalls,
358        Some("content_filter") => FinishReason::ContentFilter,
359        Some(other) => FinishReason::Other(other.to_string()),
360    }
361}
362
363/// Parse an OpenAI usage object (provider-reported only). The
364/// `prompt_tokens_details.cached_tokens` field (DW-AI-03) carries the
365/// number of prompt tokens served from the provider's prompt cache.
366fn parse_usage(v: &Value) -> Usage {
367    Usage {
368        prompt_tokens: v.get("prompt_tokens").and_then(Value::as_u64),
369        completion_tokens: v.get("completion_tokens").and_then(Value::as_u64),
370        total_tokens: v.get("total_tokens").and_then(Value::as_u64),
371        cached_tokens: v
372            .get("prompt_tokens_details")
373            .and_then(|d| d.get("cached_tokens"))
374            .and_then(Value::as_u64),
375    }
376}
377
378/// AI-06 (#196): Serialize the canonical `ResponseFormat` back to the
379/// OpenAI wire shape.
380fn response_format_to_openai(rf: &crate::ai::types::ResponseFormat) -> Value {
381    use crate::ai::types::ResponseFormat;
382    match rf {
383        ResponseFormat::Text => json!({"type": "text"}),
384        ResponseFormat::JsonObject => json!({"type": "json_object"}),
385        ResponseFormat::JsonSchema { name, schema } => json!({
386            "type": "json_schema",
387            "json_schema": {"name": name, "schema": schema}
388        }),
389    }
390}