context_harness/agent_model/
openai.rs1use 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 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 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 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") => {} 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 => {} 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()), }
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 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;