Skip to main content

xz_provider/protocol/
openai_chat.rs

1//! OpenAI Chat Completions protocol adapter (`/v1/chat/completions`).
2//!
3//! Thin adapter: HTTP shape lives in [`super::openai_wire`].
4
5use serde_json::Value;
6
7use crate::error::ProviderError;
8use crate::protocol::openai_wire::{
9    apply_chat_completion_options, map_openai_finish_reason, openai_style_auth_headers,
10    parse_chat_usage, to_openai_messages,
11};
12use crate::protocol::{AuthMethod, ProtocolAdapter};
13use crate::types::{CompletionRequest, CompletionResponse, StreamEvent, ToolCall};
14
15/// Adapter for the OpenAI Chat Completions API protocol.
16#[derive(Debug)]
17pub struct OpenAiChatAdapter;
18
19impl OpenAiChatAdapter {
20    /// Create a new OpenAI Chat Completions protocol adapter.
21    pub fn new() -> Self {
22        Self
23    }
24}
25
26impl Default for OpenAiChatAdapter {
27    fn default() -> Self {
28        Self::new()
29    }
30}
31
32impl ProtocolAdapter for OpenAiChatAdapter {
33    fn endpoint_path(&self) -> &str {
34        "/chat/completions"
35    }
36
37    fn build_request_body(
38        &self,
39        request: &CompletionRequest,
40        stream: bool,
41    ) -> Result<Value, ProviderError> {
42        let model = request.model.as_deref().unwrap_or("gpt-4o");
43        let messages = to_openai_messages(&request.messages)?;
44
45        let mut body = serde_json::json!({
46            "model": model,
47            "messages": messages,
48            "stream": stream,
49        });
50        apply_chat_completion_options(&mut body, request)?;
51        Ok(body)
52    }
53
54    fn build_auth_headers(&self, auth: &AuthMethod) -> Vec<(String, String)> {
55        openai_style_auth_headers(auth)
56    }
57
58    fn parse_response(&self, body: &Value) -> Result<CompletionResponse, ProviderError> {
59        let model = body["model"].as_str().unwrap_or("unknown").to_owned();
60
61        let choice = &body["choices"][0];
62        if choice.is_null() {
63            return Err(ProviderError::Format("missing choices[0] in response".to_owned()));
64        }
65
66        let message_obj = choice["message"]
67            .as_object()
68            .ok_or_else(|| ProviderError::Format("missing message in response".to_owned()))?;
69
70        let content = message_obj["content"].as_str().map(|s| s.to_owned());
71
72        let tool_calls: Vec<ToolCall> = message_obj
73            .get("tool_calls")
74            .and_then(|tc| tc.as_array())
75            .map(|calls| {
76                calls
77                    .iter()
78                    .filter_map(|call| {
79                        Some(ToolCall {
80                            id: call["id"].as_str()?.to_owned(),
81                            function_name: call["function"]["name"].as_str()?.to_owned(),
82                            arguments: serde_json::from_str(
83                                call["function"]["arguments"].as_str()?,
84                            )
85                            .ok()?,
86                        })
87                    })
88                    .collect()
89            })
90            .unwrap_or_default();
91
92        let finish_reason = map_openai_finish_reason(choice["finish_reason"].as_str());
93        let usage = parse_chat_usage(&body["usage"]);
94
95        let thinking =
96            message_obj.get("reasoning_content").and_then(|v| v.as_str()).map(|s| s.to_string());
97
98        let refusal = message_obj.get("refusal").and_then(|v| v.as_str()).map(|s| s.to_string());
99
100        Ok(CompletionResponse {
101            content,
102            thinking,
103            tool_calls,
104            usage,
105            model,
106            finish_reason,
107            id: body["id"].as_str().map(|s| s.to_string()),
108            created: body["created"].as_u64(),
109            system_fingerprint: body["system_fingerprint"].as_str().map(|s| s.to_string()),
110            refusal,
111            ..Default::default()
112        })
113    }
114
115    fn parse_sse_event(&self, data: &str) -> Result<Option<StreamEvent>, ProviderError> {
116        if data == "[DONE]" {
117            return Ok(None);
118        }
119
120        let parsed: Value = serde_json::from_str(data)?;
121
122        let has_choices =
123            parsed.get("choices").and_then(|c| c.as_array()).is_some_and(|arr| !arr.is_empty());
124
125        if has_choices {
126            let choice = &parsed["choices"][0];
127
128            // tool_calls first: a chunk that also carries content/reasoning
129            // must not drop tools via early-return on the other field.
130            if let Some(tool_calls) = choice["delta"]["tool_calls"].as_array() {
131                // Prefer the lowest index so parallel fan-out stays ordered.
132                // (Multiple indices in one SSE event: subsequent indices are
133                // usually repeated in following events; we still pick the
134                // first complete-looking entry here.)
135                let mut chosen: Option<&Value> = None;
136                let mut chosen_index = usize::MAX;
137                for tc in tool_calls {
138                    if let Some(index) = tc["index"].as_u64() {
139                        let idx = index as usize;
140                        if idx < chosen_index {
141                            chosen_index = idx;
142                            chosen = Some(tc);
143                        }
144                    }
145                }
146                if let Some(tc) = chosen {
147                    return Ok(Some(StreamEvent::ToolCallDelta {
148                        index: chosen_index,
149                        id: tc["id"].as_str().map(String::from),
150                        function_name: tc["function"]["name"].as_str().map(String::from),
151                        arguments_delta: tc["function"]["arguments"]
152                            .as_str()
153                            .unwrap_or("")
154                            .to_owned(),
155                    }));
156                }
157            }
158
159            if let Some(reasoning) = choice["delta"]["reasoning_content"].as_str() {
160                if !reasoning.is_empty() {
161                    return Ok(Some(StreamEvent::ThinkingDelta {
162                        delta: reasoning.to_owned(),
163                    }));
164                }
165            }
166
167            if let Some(content) = choice["delta"]["content"].as_str() {
168                if !content.is_empty() {
169                    return Ok(Some(StreamEvent::ContentDelta { delta: content.to_owned() }));
170                }
171            }
172
173            if let Some(fr) = choice["finish_reason"].as_str() {
174                if !fr.is_empty() {
175                    let finish_reason = map_openai_finish_reason(Some(fr));
176                    let usage = parsed.get("usage").filter(|u| u.is_object()).map(parse_chat_usage);
177                    return Ok(Some(StreamEvent::Done { finish_reason, usage }));
178                }
179            }
180        }
181
182        if let Some(usage_val) = parsed.get("usage").filter(|u| u.is_object()) {
183            return Ok(Some(StreamEvent::Usage { usage: parse_chat_usage(usage_val) }));
184        }
185
186        Ok(None)
187    }
188
189    fn protocol_name(&self) -> &str {
190        "openai-chat"
191    }
192}
193
194#[cfg(test)]
195#[path = "openai_chat_tests.rs"]
196mod tests;