Skip to main content

xz_provider/protocol/
openai_responses.rs

1//! Protocol adapter for the OpenAI Responses API (`POST /v1/responses`).
2//!
3//! Converts between the unified [`CompletionRequest`]/[`CompletionResponse`] types
4//! and the OpenAI Responses API wire format (both non-streaming and SSE streaming).
5//!
6//! # SSE Event Parsing
7//!
8//! The Responses API uses event-typed SSE where each `data:` line contains a JSON
9//! payload with a `type` field. The [`ProtocolAdapter::parse_sse_event`] method uses this embedded
10//! `type` field (not the `event:` line) to determine the event kind:
11//!
12//! | `type` field value | Resulting [`StreamEvent`] |
13//! |---|---|
14//! | `response.output_text.delta` | [`StreamEvent::ContentDelta`] |
15//! | `response.done` | [`StreamEvent::Done`] (with finish_reason and optional usage) |
16//! | `[DONE]` sentinel | [`StreamEvent::Done`] (finish_reason=Stop, usage=None) |
17//! | other / unknown | ignored (`Ok(None)`) |
18
19use serde_json::Value;
20use tracing;
21
22use crate::error::ProviderError;
23use crate::protocol::{AuthMethod, ProtocolAdapter};
24use crate::types::{
25    CompletionRequest, CompletionResponse, ContentPart, FinishReason, Message, MessageContent,
26    StreamEvent, TokenUsage, ToolCall, ToolDefinition,
27};
28
29/// Protocol adapter for the OpenAI Responses API.
30///
31/// # Behaviour
32///
33/// - **Endpoint**: `POST <base_url>/responses`
34/// - **Auth**: [`AuthMethod::Bearer`] (Bearer token in `Authorization` header)
35/// - **Request body**: OpenAI Responses JSON format with `model`, `input`, and
36///   optional `instructions`, `stream`, `temperature`, etc.
37/// - **Non-streaming response**: Standard Responses API JSON object
38/// - **SSE stream**: Event-typed SSE where data JSON contains a `type` discriminator
39///
40/// # Example
41///
42/// ```rust
43/// use xz_provider::protocol::{ProtocolAdapter, AuthMethod, openai_responses::OpenAiResponsesAdapter};
44/// use xz_provider::{CompletionRequest, Message};
45///
46/// let adapter = OpenAiResponsesAdapter::new();
47/// assert_eq!(adapter.endpoint_path(), "/responses");
48/// assert_eq!(adapter.protocol_name(), "openai_responses");
49///
50/// // Build auth headers for Bearer token
51/// let headers = adapter.build_auth_headers(&AuthMethod::Bearer { token: "sk-test".into() });
52/// assert_eq!(headers[0].0, "Authorization");
53/// assert_eq!(headers[0].1, "Bearer sk-test");
54/// ```
55#[derive(Debug, Clone)]
56pub struct OpenAiResponsesAdapter;
57
58impl OpenAiResponsesAdapter {
59    /// Create a new `OpenAiResponsesAdapter`.
60    pub fn new() -> Self {
61        Self
62    }
63}
64
65impl Default for OpenAiResponsesAdapter {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71impl ProtocolAdapter for OpenAiResponsesAdapter {
72    fn endpoint_path(&self) -> &str {
73        "/responses"
74    }
75
76    fn build_request_body(
77        &self,
78        request: &CompletionRequest,
79        stream: bool,
80    ) -> Result<Value, ProviderError> {
81        let model = request.model.as_deref().unwrap_or("gpt-4o");
82
83        // Extract system message(s) as instructions; convert others to input items
84        let mut instructions = String::new();
85        let mut input_items: Vec<Value> = Vec::new();
86
87        for msg in &request.messages {
88            match msg {
89                Message::System { content, .. } | Message::Developer { content, .. } => {
90                    if let MessageContent::Text(text) = content {
91                        if !instructions.is_empty() {
92                            instructions.push('\n');
93                        }
94                        instructions.push_str(text);
95                    }
96                    // Multi-part system content: append the display text
97                    if let MessageContent::MultiPart(parts) = content {
98                        for part in parts {
99                            if let ContentPart::Text { text } = part {
100                                if !instructions.is_empty() {
101                                    instructions.push('\n');
102                                }
103                                instructions.push_str(text);
104                            }
105                        }
106                    }
107                }
108                Message::User { content } => {
109                    let content_val = message_content_to_value(content);
110                    input_items.push(serde_json::json!({
111                        "role": "user",
112                        "content": content_val,
113                    }));
114                }
115                Message::Assistant { content, tool_calls, .. } => {
116                    let content_val = message_content_to_value(content);
117                    let mut item = serde_json::json!({
118                        "role": "assistant",
119                        "content": content_val,
120                    });
121                    if let Some(calls) = tool_calls {
122                        if !calls.is_empty() {
123                            item["tool_calls"] = serde_json::to_value(calls)?;
124                            // OpenAI requires content=null when tool_calls present
125                            item["content"] = Value::Null;
126                        }
127                    }
128                    input_items.push(item);
129                }
130                Message::Tool { content, tool_call_id, .. } => {
131                    let content_val = message_content_to_value(content);
132                    input_items.push(serde_json::json!({
133                        "role": "tool",
134                        "content": content_val,
135                        "tool_call_id": tool_call_id,
136                    }));
137                }
138            }
139        }
140
141        let mut body = serde_json::json!({
142            "model": model,
143            "input": input_items,
144        });
145
146        if !instructions.is_empty() {
147            body["instructions"] = Value::String(instructions);
148        }
149
150        if stream {
151            body["stream"] = Value::Bool(true);
152        }
153
154        // Optional generation parameters
155        if let Some(temp) = request.temperature {
156            body["temperature"] = serde_json::to_value(temp)?;
157        }
158        if let Some(max_tokens) = request.max_tokens {
159            body["max_tokens"] = serde_json::to_value(max_tokens)?;
160        }
161        if let Some(max_ct) = request.max_completion_tokens {
162            body["max_completion_tokens"] = serde_json::to_value(max_ct)?;
163        }
164        if let Some(stop) = &request.stop {
165            body["stop"] = serde_json::to_value(stop)?;
166        }
167        if let Some(top_p) = request.top_p {
168            body["top_p"] = serde_json::to_value(top_p)?;
169        }
170        if let Some(seed) = request.seed {
171            body["seed"] = serde_json::to_value(seed)?;
172        }
173        if let Some(ref re) = request.reasoning_effort {
174            body["reasoning_effort"] = serde_json::to_value(re)?;
175        }
176        if let Some(tools) = &request.tools {
177            body["tools"] = Value::Array(to_responses_api_tools(tools));
178        }
179        if let Some(tool_choice) = &request.tool_choice {
180            body["tool_choice"] = serde_json::to_value(tool_choice)?;
181        }
182        if let Some(response_format) = &request.response_format {
183            body["response_format"] = serde_json::to_value(response_format)?;
184        }
185
186        // New request fields
187        if let Some(thinking) = &request.thinking {
188            body["thinking"] = serde_json::to_value(thinking)?;
189        }
190        if let Some(user) = &request.user {
191            body["user"] = serde_json::to_value(user)?;
192        }
193
194        Ok(body)
195    }
196
197    fn build_auth_headers(&self, auth: &AuthMethod) -> Vec<(String, String)> {
198        match auth {
199            AuthMethod::None => vec![],
200            AuthMethod::Bearer { token } => {
201                vec![("Authorization".to_owned(), format!("Bearer {}", token))]
202            }
203            AuthMethod::ApiKey { header_name, key } => {
204                vec![(header_name.clone(), key.clone())]
205            }
206        }
207    }
208
209    fn parse_response(&self, body: &Value) -> Result<CompletionResponse, ProviderError> {
210        let model = body["model"].as_str().unwrap_or("unknown").to_owned();
211
212        // Extract top-level response fields
213        let id = body["id"].as_str().map(String::from);
214        let created = body["created"].as_u64();
215        let system_fingerprint = body["system_fingerprint"].as_str().map(String::from);
216
217        // Extract text content, refusal, and reasoning from output[].message.content[] items
218        let mut content = String::new();
219        let mut tool_calls: Vec<ToolCall> = Vec::new();
220        let mut refusal: Option<String> = None;
221        let mut reasoning: Option<String> = None;
222
223        if let Some(outputs) = body["output"].as_array() {
224            for item in outputs {
225                match item["type"].as_str() {
226                    Some("message") => {
227                        if let Some(contents) = item["content"].as_array() {
228                            for c in contents {
229                                match c["type"].as_str() {
230                                    Some("output_text") => {
231                                        if let Some(text) = c["text"].as_str() {
232                                            if !content.is_empty() {
233                                                content.push('\n');
234                                            }
235                                            content.push_str(text);
236                                        }
237                                    }
238                                    Some("refusal") => {
239                                        if let Some(text) = c["text"].as_str() {
240                                            refusal = Some(text.to_owned());
241                                        }
242                                    }
243                                    Some("reasoning") => {
244                                        if let Some(text) = c["text"].as_str() {
245                                            reasoning = Some(text.to_owned());
246                                        }
247                                    }
248                                    _ => {}
249                                }
250                            }
251                        }
252                    }
253                    Some("function_call") => {
254                        let args_str = item["arguments"].as_str().unwrap_or("{}");
255                        let arguments = serde_json::from_str(args_str).unwrap_or(Value::Null);
256                        tool_calls.push(ToolCall {
257                            id: item["id"].as_str().unwrap_or("").to_owned(),
258                            function_name: item["name"].as_str().unwrap_or("").to_owned(),
259                            arguments,
260                        });
261                    }
262                    _ => {}
263                }
264            }
265        }
266
267        let content_opt = if content.is_empty() { None } else { Some(content) };
268
269        // Parse usage: Responses API uses input_tokens / output_tokens
270        let usage = parse_responses_usage(body);
271
272        // Parse finish_reason from the response status
273        let finish_reason = parse_responses_finish_reason(body, refusal.is_some());
274
275        Ok(CompletionResponse {
276            content: content_opt,
277            thinking: reasoning,
278            tool_calls,
279            usage,
280            model,
281            finish_reason,
282            id,
283            created,
284            system_fingerprint,
285            refusal,
286            ..Default::default()
287        })
288    }
289
290    fn parse_sse_event(&self, data: &str) -> Result<Option<StreamEvent>, ProviderError> {
291        // Handle [DONE] sentinel — ignore; Done event comes from response.done
292        if data == "[DONE]" {
293            return Ok(None);
294        }
295
296        let parsed: Value = serde_json::from_str(data)?;
297
298        match parsed["type"].as_str() {
299            Some("response.output_text.delta") => {
300                let delta = parsed.get("delta").and_then(|v| v.as_str()).unwrap_or("");
301                if delta.is_empty() {
302                    Ok(None)
303                } else {
304                    Ok(Some(StreamEvent::ContentDelta { delta: delta.to_owned() }))
305                }
306            }
307            Some("response.done") => {
308                let response = &parsed["response"];
309                // SSE done events don't carry output content, so has_refusal is always false
310                let finish_reason = parse_responses_finish_reason(response, false);
311
312                let usage = parse_responses_usage(response);
313                let usage_opt = if usage.prompt_tokens == 0 && usage.completion_tokens == 0 {
314                    None
315                } else {
316                    Some(usage)
317                };
318
319                Ok(Some(StreamEvent::Done { finish_reason, usage: usage_opt }))
320            }
321            Some("response.function_call_arguments.delta") => {
322                // Tool call streaming for Responses API
323                let index = parsed
324                    .get("item_id")
325                    .and_then(|v| v.as_str())
326                    .and_then(|s| s.rsplit('_').next())
327                    .and_then(|n| n.parse::<usize>().ok())
328                    .unwrap_or(0);
329                let arguments_delta =
330                    parsed.get("delta").and_then(|v| v.as_str()).unwrap_or("").to_owned();
331                let id = None;
332                let function_name = None;
333
334                Ok(Some(StreamEvent::ToolCallDelta { index, id, function_name, arguments_delta }))
335            }
336            _ => {
337                // Unknown event type — silently ignored per spec
338                tracing::trace!("ignoring unknown Responses SSE event type");
339                Ok(None)
340            }
341        }
342    }
343
344    fn protocol_name(&self) -> &str {
345        "openai_responses"
346    }
347}
348
349// ── Helper functions ──
350
351/// Convert [`MessageContent`] to a JSON value for the `content` field in
352/// Responses API input items.
353fn message_content_to_value(content: &MessageContent) -> Value {
354    match content {
355        MessageContent::Text(text) => Value::String(text.clone()),
356        MessageContent::MultiPart(parts) => {
357            let items: Vec<Value> = parts
358                .iter()
359                .map(|part| match part {
360                    ContentPart::Text { text } => {
361                        serde_json::json!({"type": "text", "text": text})
362                    }
363                    ContentPart::ImageUrl { url, detail } => {
364                        let mut obj = serde_json::json!({
365                            "type": "image_url",
366                            "image_url": { "url": url }
367                        });
368                        if let Some(d) = detail {
369                            obj["image_url"]["detail"] =
370                                serde_json::to_value(d).unwrap_or(Value::Null);
371                        }
372                        obj
373                    }
374                    ContentPart::ImageBase64 { media_type, data } => {
375                        serde_json::json!({
376                            "type": "image_url",
377                            "image_url": {
378                                "url": format!("data:{};base64,{}", media_type, data)
379                            }
380                        })
381                    }
382                    _ => serde_json::json!({"type": "text", "text": ""}),
383                })
384                .collect();
385            Value::Array(items)
386        }
387        MessageContent::None => Value::Null,
388    }
389}
390
391/// Parse usage from a Responses API response body or SSE `response` object.
392///
393/// The Responses API uses `input_tokens` / `output_tokens` instead of
394/// `prompt_tokens` / `completion_tokens`.
395fn parse_responses_usage(body: &Value) -> TokenUsage {
396    let usage_data = &body["usage"];
397    if usage_data.is_object() {
398        let prompt = usage_data["input_tokens"].as_u64().unwrap_or(0) as u32;
399        let completion = usage_data["output_tokens"].as_u64().unwrap_or(0) as u32;
400        TokenUsage {
401            prompt_tokens: prompt,
402            completion_tokens: completion,
403            total_tokens: prompt + completion,
404            cached_tokens: None,
405            ..Default::default()
406        }
407    } else {
408        TokenUsage::new(0, 0)
409    }
410}
411
412/// Parse finish_reason from a Responses API response body.
413///
414/// Maps `status`:
415/// - `completed` → [`FinishReason::Stop`]
416/// - `incomplete` with `max_output_tokens` reason → [`FinishReason::MaxTokens`]
417/// - `incomplete` with `content_filter` reason → [`FinishReason::ContentFilter`]
418/// - `incomplete` with `refusal` reason → [`FinishReason::Refusal`]
419/// - `incomplete` with unknown reason → [`FinishReason::Stop`] (with warning)
420/// - `pause_turn` → [`FinishReason::PauseTurn`]
421/// - any other status → [`FinishReason::Stop`] (with warning)
422///
423/// If the output contains a `refusal` content item (detected by the `has_refusal`
424/// parameter), returns [`FinishReason::Refusal`] regardless of status.
425fn parse_responses_finish_reason(body: &Value, has_refusal: bool) -> FinishReason {
426    if has_refusal {
427        return FinishReason::Refusal;
428    }
429
430    match body["status"].as_str() {
431        Some("completed") => FinishReason::Stop,
432        Some("incomplete") => match body["incomplete_details"]["reason"].as_str() {
433            Some("max_output_tokens") => FinishReason::MaxTokens,
434            Some("content_filter") => FinishReason::ContentFilter,
435            Some("refusal") => FinishReason::Refusal,
436            reason => {
437                tracing::warn!(
438                    "unknown incomplete_details.reason: {:?}, defaulting to Stop",
439                    reason
440                );
441                FinishReason::Stop
442            }
443        },
444        Some("pause_turn") => FinishReason::PauseTurn,
445        Some(other) => {
446            tracing::warn!("unknown response status: {}, defaulting to Stop", other);
447            FinishReason::Stop
448        }
449        None => FinishReason::Stop,
450    }
451}
452
453/// Convert [`ToolDefinition`] slice to the Responses API tool format.
454///
455/// The Responses API uses the same tool format as Chat Completions:
456/// `{"type": "function", "function": { "name": "...", "description": "...", "parameters": {...} }}`
457fn to_responses_api_tools(tools: &[ToolDefinition]) -> Vec<Value> {
458    tools
459        .iter()
460        .map(|t| {
461            serde_json::json!({
462                "type": "function",
463                "function": {
464                    "name": t.name,
465                    "description": t.description,
466                    "parameters": t.parameters,
467                    "strict": t.strict,
468                }
469            })
470        })
471        .collect()
472}
473
474#[cfg(test)]
475#[path = "openai_responses_tests.rs"]
476mod tests;