Skip to main content

opendev_http/adapters/openai/
mod.rs

1//! OpenAI Responses API adapter.
2//!
3//! The Responses API (`/v1/responses`) is OpenAI's recommended replacement for
4//! Chat Completions.  This adapter transparently converts the internal
5//! Chat Completions-shaped payload to the Responses API format and converts
6//! responses back so the rest of the agent code is unaffected.
7//!
8//! See: https://platform.openai.com/docs/guides/migrate-to-responses
9
10mod request;
11mod response;
12
13use serde_json::{Value, json};
14
15const DEFAULT_API_URL: &str = "https://api.openai.com/v1/responses";
16
17/// Set of model prefixes that are reasoning models (o1, o3).
18const REASONING_PREFIXES: &[&str] = &["o1", "o3"];
19
20/// Adapter for the OpenAI Responses API.
21///
22/// Converts internal Chat Completions payloads to the Responses API format
23/// and converts responses back to Chat Completions format.
24#[derive(Debug, Clone)]
25pub struct OpenAiAdapter {
26    api_url: String,
27}
28
29impl OpenAiAdapter {
30    /// Create a new OpenAI adapter with the default Responses API URL.
31    pub fn new() -> Self {
32        Self {
33            api_url: DEFAULT_API_URL.to_string(),
34        }
35    }
36
37    /// Create with a custom API URL (for Azure, proxies, etc.).
38    pub fn with_url(url: impl Into<String>) -> Self {
39        Self {
40            api_url: url.into(),
41        }
42    }
43}
44
45impl Default for OpenAiAdapter {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51#[async_trait::async_trait]
52impl super::base::ProviderAdapter for OpenAiAdapter {
53    fn provider_name(&self) -> &str {
54        "openai"
55    }
56
57    fn convert_request(&self, payload: Value) -> Value {
58        let mut payload = payload;
59
60        // Extract and remove internal reasoning effort field
61        let reasoning_effort = payload
62            .as_object_mut()
63            .and_then(|obj| obj.remove("_reasoning_effort"))
64            .and_then(|v| v.as_str().map(String::from));
65
66        let messages = payload
67            .get("messages")
68            .and_then(|m| m.as_array())
69            .cloned()
70            .unwrap_or_default();
71
72        let (instructions, input_items) = Self::convert_messages(&messages);
73
74        let mut responses_payload = json!({
75            "model": payload.get("model").cloned().unwrap_or(json!("")),
76            "input": input_items,
77            "store": false,
78        });
79
80        if let Some(instr) = instructions {
81            responses_payload["instructions"] = Value::String(instr);
82        }
83
84        // max_tokens / max_completion_tokens → max_output_tokens
85        let max_tok = payload
86            .get("max_completion_tokens")
87            .or_else(|| payload.get("max_tokens"));
88        if let Some(tok) = max_tok {
89            responses_payload["max_output_tokens"] = tok.clone();
90        }
91
92        // Reasoning config — always request when effort is configured.
93        // Works for o-series, GPT-5+, and any future reasoning-capable models.
94        // Non-reasoning models will simply not return reasoning output.
95        if let Some(ref effort) = reasoning_effort {
96            responses_payload["reasoning"] = json!({
97                "effort": effort,
98                "summary": "detailed",
99            });
100            // Include encrypted reasoning content for full thinking traces.
101            // Without this, OpenAI only returns brief summaries.
102            responses_payload["include"] = json!(["reasoning.encrypted_content"]);
103            // OpenAI rejects temperature when reasoning is set
104        } else if !Self::is_reasoning_model(&payload) {
105            // Temperature (only when reasoning is NOT active)
106            if let Some(temp) = payload.get("temperature") {
107                responses_payload["temperature"] = temp.clone();
108            }
109        }
110
111        // Tools
112        if let Some(tools) = payload.get("tools").and_then(|t| t.as_array()) {
113            responses_payload["tools"] = Value::Array(Self::convert_tools(tools));
114        }
115
116        responses_payload
117    }
118
119    fn convert_response(&self, response: Value) -> Value {
120        Self::build_chat_completion(&response)
121    }
122
123    fn api_url(&self) -> &str {
124        &self.api_url
125    }
126
127    fn supports_streaming(&self) -> bool {
128        true
129    }
130
131    fn enable_streaming(&self, payload: &mut Value) {
132        payload["stream"] = json!(true);
133    }
134
135    fn parse_stream_event(
136        &self,
137        event_type: &str,
138        data: &Value,
139    ) -> Option<crate::streaming::StreamEvent> {
140        use crate::streaming::StreamEvent;
141        match event_type {
142            "response.output_text.delta" => {
143                let delta = data.get("delta")?.as_str()?;
144                Some(StreamEvent::TextDelta(delta.to_string()))
145            }
146            "response.reasoning_summary_part.added" => Some(StreamEvent::ReasoningBlockStart),
147            "response.reasoning_summary_text.delta" => {
148                let delta = data.get("delta")?.as_str()?;
149                Some(StreamEvent::ReasoningDelta(delta.to_string()))
150            }
151            // ── Function call streaming ──
152            "response.output_item.added" => {
153                let item = data.get("item")?;
154                if item.get("type").and_then(|t| t.as_str()) != Some("function_call") {
155                    return None;
156                }
157                let index = data
158                    .get("output_index")
159                    .and_then(|i| i.as_u64())
160                    .unwrap_or(0) as usize;
161                let call_id = item
162                    .get("call_id")
163                    .or_else(|| item.get("id"))
164                    .and_then(|i| i.as_str())
165                    .unwrap_or("")
166                    .to_string();
167                let name = item
168                    .get("name")
169                    .and_then(|n| n.as_str())
170                    .unwrap_or("")
171                    .to_string();
172                Some(StreamEvent::FunctionCallStart {
173                    index,
174                    call_id,
175                    name,
176                })
177            }
178            "response.function_call_arguments.delta" => {
179                let index = data
180                    .get("output_index")
181                    .and_then(|i| i.as_u64())
182                    .unwrap_or(0) as usize;
183                let delta = data.get("delta")?.as_str()?.to_string();
184                Some(StreamEvent::FunctionCallDelta { index, delta })
185            }
186            "response.function_call_arguments.done" => {
187                let index = data
188                    .get("output_index")
189                    .and_then(|i| i.as_u64())
190                    .unwrap_or(0) as usize;
191                let arguments = data
192                    .get("arguments")
193                    .and_then(|a| a.as_str())
194                    .unwrap_or("{}")
195                    .to_string();
196                Some(StreamEvent::FunctionCallDone { index, arguments })
197            }
198            // ── Stream lifecycle ──
199            "response.completed" | "response.incomplete" => {
200                let response = data
201                    .get("response")
202                    .cloned()
203                    .unwrap_or_else(|| data.clone());
204                Some(StreamEvent::Done(response))
205            }
206            "error" => {
207                let msg = data
208                    .get("message")
209                    .and_then(|m| m.as_str())
210                    .unwrap_or("Unknown streaming error");
211                Some(StreamEvent::Error(msg.to_string()))
212            }
213            _ => None,
214        }
215    }
216}
217
218#[cfg(test)]
219mod tests;