Skip to main content

context_harness/agent_model/
openai.rs

1//! OpenAI Responses adapter: stateless, non-streaming, caller-owned history.
2//! See SPEC-0016 for supported items and intentionally deferred streaming.
3use super::*;
4use reqwest::{redirect::Policy, Client};
5use std::time::Duration;
6
7const MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
8
9pub struct OpenAiProvider {
10    model: String,
11    api_key_env: String,
12    endpoint: String,
13    client: Client,
14    #[cfg(test)]
15    test_key: Option<String>,
16}
17impl OpenAiProvider {
18    pub fn new(model: &str, api_key_env: &str) -> anyhow::Result<Self> {
19        ModelDefinition {
20            provider: "openai".into(),
21            model: model.into(),
22            api_key_env: Some(api_key_env.into()),
23            ..Default::default()
24        }
25        .validate()?;
26        Ok(Self {
27            model: model.into(),
28            api_key_env: api_key_env.into(),
29            endpoint: "https://api.openai.com/v1/responses".into(),
30            client: Client::builder()
31                .timeout(Duration::from_secs(60))
32                .connect_timeout(Duration::from_secs(10))
33                .redirect(Policy::none())
34                .build()?,
35            #[cfg(test)]
36            test_key: None,
37        })
38    }
39
40    fn credential(&self) -> ModelResult<String> {
41        #[cfg(test)]
42        if let Some(key) = &self.test_key {
43            return Ok(key.clone());
44        }
45        std::env::var(&self.api_key_env)
46            .ok()
47            .filter(|s| !s.trim().is_empty())
48            .ok_or_else(|| ModelError::new(ModelErrorKind::MissingCredentials))
49    }
50
51    fn body(&self, request: &ModelRequest) -> ModelResult<Value> {
52        request.validate()?;
53        let mut input = Vec::new();
54        for message in &request.messages {
55            match message {
56                ModelMessage::System { content } => {
57                    input.push(json!({"role": "system", "content": content}))
58                }
59                ModelMessage::User { content } => {
60                    input.push(json!({"role": "user", "content": content}))
61                }
62                ModelMessage::Tool { call_id, content } => input.push(
63                    json!({"type": "function_call_output", "call_id": call_id, "output": content}),
64                ),
65                ModelMessage::Assistant {
66                    text,
67                    tool_calls,
68                    continuation,
69                } => {
70                    if let Some(state) = continuation {
71                        if state.provider != "openai" {
72                            return Err(ModelError::new(ModelErrorKind::InvalidRequest));
73                        }
74                        let names = tool_calls
75                            .iter()
76                            .map(|call| (wire_name(&call.name), call.name.clone()))
77                            .collect();
78                        let (saved_text, saved_calls, _) = decode_items(&state.items, &names, true)
79                            .map_err(|_| ModelError::new(ModelErrorKind::InvalidRequest))?;
80                        if saved_text != *text || saved_calls != *tool_calls {
81                            return Err(ModelError::new(ModelErrorKind::InvalidRequest));
82                        }
83                        input.extend(state.items.clone());
84                    } else {
85                        if !text.is_empty() {
86                            input.push(json!({"role": "assistant", "content": text}));
87                        }
88                        for call in tool_calls {
89                            input.push(json!({"type": "function_call", "call_id": call.id,
90                                "name": wire_name(&call.name), "arguments": call.arguments.to_string()}));
91                        }
92                    }
93                }
94            }
95        }
96        // Registry names may contain dots; OpenAI function names cannot. Stable
97        // 64-character hashes preserve distinct names without lossy substitution.
98        let tools: Vec<Value> = request
99            .tools
100            .iter()
101            .map(|tool| {
102                json!({
103                    "type": "function", "name": wire_name(&tool.name),
104                    "description": format!("{}: {}", tool.name, tool.description),
105                    "parameters": tool.parameters, "strict": false
106                })
107            })
108            .collect();
109        let mut body = json!({"model": self.model, "input": input, "tools": tools,
110            "store": false, "stream": false, "include": ["reasoning.encrypted_content"]});
111        if let Some(limit) = request.max_output_tokens {
112            body["max_output_tokens"] = json!(limit);
113        }
114        if let Some(output) = &request.output_schema {
115            body["text"] = json!({"format": {"type": "json_schema", "name": output.name, "schema": output.schema, "strict": true}});
116        }
117        Ok(body)
118    }
119
120    fn decode(&self, body: Value, request: &ModelRequest) -> ModelResult<ModelResponse> {
121        let invalid = || ModelError::new(ModelErrorKind::InvalidResponse);
122        let status = body["status"].as_str().ok_or_else(invalid)?;
123        if status == "failed" {
124            return Err(ModelError::new(ModelErrorKind::ProviderFailure));
125        }
126        if status != "completed" && status != "incomplete" {
127            return Err(invalid());
128        }
129        let items = body["output"].as_array().ok_or_else(invalid)?;
130        let names = request
131            .tools
132            .iter()
133            .map(|tool| (wire_name(&tool.name), tool.name.clone()))
134            .collect();
135        let (text, mut tool_calls, refused) = decode_items(items, &names, status == "completed")?;
136        let finish_reason = if status == "incomplete" {
137            // Partial calls are not executable, even if their arguments parse.
138            tool_calls.clear();
139            match body["incomplete_details"]["reason"].as_str() {
140                Some("max_output_tokens") => FinishReason::Length,
141                Some("content_filter") => FinishReason::ContentFilter,
142                _ => return Err(invalid()),
143            }
144        } else if refused {
145            tool_calls.clear();
146            FinishReason::Refusal
147        } else if !tool_calls.is_empty() {
148            FinishReason::ToolCalls
149        } else {
150            if text.is_empty() {
151                return Err(invalid());
152            }
153            FinishReason::Completed
154        };
155        let usage = match body.get("usage").filter(|v| !v.is_null()) {
156            Some(value) => Some(serde_json::from_value(value.clone()).map_err(|_| invalid())?),
157            None => None,
158        };
159        let structured_output =
160            if request.output_schema.is_some() && finish_reason == FinishReason::Completed {
161                Some(serde_json::from_str(&text).map_err(|_| invalid())?)
162            } else {
163                None
164            };
165        // Incomplete/refused responses cannot be resumed as successful turns.
166        let continuation = if matches!(
167            finish_reason,
168            FinishReason::Completed | FinishReason::ToolCalls
169        ) {
170            Some(ProviderContinuation {
171                provider: "openai".into(),
172                items: items.clone(),
173            })
174        } else {
175            None
176        };
177        let response = ModelResponse {
178            text,
179            tool_calls,
180            usage,
181            finish_reason,
182            structured_output,
183            continuation,
184        };
185        response.validate(request)?;
186        Ok(response)
187    }
188}
189
190fn decode_items(
191    items: &[Value],
192    names: &BTreeMap<String, String>,
193    complete: bool,
194) -> ModelResult<(String, Vec<ToolCall>, bool)> {
195    let invalid = || ModelError::new(ModelErrorKind::InvalidResponse);
196    let mut text = String::new();
197    let mut calls = Vec::new();
198    let mut refused = false;
199    for item in items {
200        match item["type"].as_str() {
201            Some("reasoning") => {} // Preserve the full item for the next request.
202            Some("message") => {
203                if item["role"].as_str() != Some("assistant") {
204                    return Err(invalid());
205                }
206                for content in item["content"].as_array().ok_or_else(invalid)? {
207                    match content["type"].as_str() {
208                        Some("output_text") => {
209                            text.push_str(content["text"].as_str().ok_or_else(invalid)?)
210                        }
211                        Some("refusal") => {
212                            refused = true;
213                            text.push_str(content["refusal"].as_str().ok_or_else(invalid)?);
214                        }
215                        _ => return Err(invalid()),
216                    }
217                }
218            }
219            Some("function_call") if !complete => {} // Truncated arguments are not executable.
220            Some("function_call") => {
221                let name = names
222                    .get(item["name"].as_str().ok_or_else(invalid)?)
223                    .ok_or_else(invalid)?
224                    .clone();
225                calls.push(ToolCall {
226                    id: item["call_id"].as_str().ok_or_else(invalid)?.into(),
227                    name,
228                    arguments: serde_json::from_str(
229                        item["arguments"].as_str().ok_or_else(invalid)?,
230                    )
231                    .map_err(|_| invalid())?,
232                });
233            }
234            _ => return Err(invalid()), // Never silently discard unsupported output.
235        }
236    }
237    Ok((text, calls, refused))
238}
239
240fn transport_error(error: reqwest::Error) -> ModelError {
241    ModelError::new(if error.is_timeout() {
242        ModelErrorKind::Timeout
243    } else {
244        ModelErrorKind::Transport
245    })
246}
247
248#[async_trait]
249impl ModelProvider for OpenAiProvider {
250    async fn generate(&self, request: &ModelRequest) -> ModelResult<ModelResponse> {
251        let body = self.body(request)?;
252        let key = self.credential()?;
253        let mut response = self
254            .client
255            .post(&self.endpoint)
256            .bearer_auth(key)
257            .json(&body)
258            .send()
259            .await
260            .map_err(transport_error)?;
261        let status = response.status();
262        if !status.is_success() {
263            // Error bodies can echo prompts or credentials; do not read/log them.
264            let kind = match status.as_u16() {
265                401 | 403 => ModelErrorKind::Authentication,
266                429 => ModelErrorKind::RateLimited,
267                500..=599 => ModelErrorKind::Unavailable,
268                400 | 404 | 422 => ModelErrorKind::InvalidRequest,
269                _ => ModelErrorKind::ProviderFailure,
270            };
271            return Err(ModelError {
272                kind,
273                http_status: Some(status.as_u16()),
274            });
275        }
276        let mut bytes = Vec::new();
277        while let Some(chunk) = response.chunk().await.map_err(transport_error)? {
278            if bytes.len().saturating_add(chunk.len()) > MAX_RESPONSE_BYTES {
279                return Err(ModelError::new(ModelErrorKind::InvalidResponse));
280            }
281            bytes.extend_from_slice(&chunk);
282        }
283        let body = serde_json::from_slice(&bytes)
284            .map_err(|_| ModelError::new(ModelErrorKind::InvalidResponse))?;
285        self.decode(body, request)
286    }
287}
288
289#[cfg(test)]
290mod tests;