1#[doc(hidden)]
32pub mod convert;
33pub mod embeddings;
34
35pub mod responses;
36pub use embeddings::OpenAIEmbeddingClient;
37pub use responses::OpenAIChatClient;
38
39use std::collections::{HashMap, VecDeque};
40use std::sync::Arc;
41
42use agent_framework_core::client::{ChatClient, ChatStream};
43use agent_framework_core::error::{Error, Result};
44use agent_framework_core::streaming::Utf8StreamDecoder;
45use agent_framework_core::types::{
46 ChatOptions, ChatResponse, ChatResponseUpdate, Content, FinishReason, FunctionArguments,
47 FunctionCallContent, Message, Role, TextContent, UsageContent,
48};
49use futures::StreamExt;
50use serde_json::{json, Map, Value};
51
52pub(crate) const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
53
54pub(crate) fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<f64> {
62 headers
63 .get(reqwest::header::RETRY_AFTER)
64 .and_then(|v| v.to_str().ok())
65 .and_then(|s| s.trim().parse::<f64>().ok())
66 .filter(|s| s.is_finite() && *s >= 0.0)
67}
68
69pub fn classify_service_error(
109 status: u16,
110 body: &str,
111 message: impl Into<String>,
112 retry_after: Option<f64>,
113) -> Error {
114 let message = message.into();
115 match status {
116 401 | 403 => Error::service_invalid_auth(message),
117 400 | 404 | 422 => {
118 if body_signals_content_filter(body) {
119 Error::service_content_filter(message)
120 } else {
121 Error::service_invalid_request(message)
122 }
123 }
124 _ => Error::service_status(status, message, retry_after),
125 }
126}
127
128fn body_signals_content_filter(body: &str) -> bool {
139 let Ok(value) = serde_json::from_str::<Value>(body) else {
140 return false;
141 };
142 let error = value.get("error").unwrap_or(&value);
143 is_content_filter_marker(error)
144 || error
145 .get("innererror")
146 .is_some_and(is_content_filter_marker)
147}
148
149fn is_content_filter_marker(v: &Value) -> bool {
150 v.get("code").and_then(Value::as_str) == Some("content_filter")
151 || v.get("type").and_then(Value::as_str) == Some("content_filter")
152}
153
154#[derive(Clone)]
156pub struct OpenAIChatCompletionClient {
157 inner: Arc<Inner>,
158}
159
160#[derive(Clone)]
161struct Inner {
162 http: reqwest::Client,
163 api_key: String,
164 base_url: String,
165 model: String,
166 organization: Option<String>,
167}
168
169impl OpenAIChatCompletionClient {
170 pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
172 Self {
173 inner: Arc::new(Inner {
174 http: reqwest::Client::new(),
175 api_key: api_key.into(),
176 base_url: DEFAULT_BASE_URL.to_string(),
177 model: model.into(),
178 organization: None,
179 }),
180 }
181 }
182
183 pub fn from_env(model: impl Into<String>) -> Result<Self> {
186 let key = std::env::var("OPENAI_API_KEY")
187 .map_err(|_| Error::Configuration("OPENAI_API_KEY is not set".into()))?;
188 let mut client = Self::new(key, model);
189 if let Ok(base) = std::env::var("OPENAI_BASE_URL") {
190 client = client.with_base_url(base);
191 }
192 Ok(client)
193 }
194
195 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
197 Arc::make_mut(&mut self.inner).base_url = base_url.into();
198 self
199 }
200
201 pub fn with_organization(mut self, org: impl Into<String>) -> Self {
203 Arc::make_mut(&mut self.inner).organization = Some(org.into());
204 self
205 }
206
207 fn build_body(&self, messages: &[Message], options: &ChatOptions, stream: bool) -> Value {
208 let mut body = Map::new();
209 let model = options
210 .model
211 .clone()
212 .unwrap_or_else(|| self.inner.model.clone());
213 body.insert("model".into(), json!(model));
214 body.insert(
215 "messages".into(),
216 json!(convert::messages_to_openai(messages)),
217 );
218 convert::apply_options(&mut body, options);
219 let (tools, tool_choice) = convert::tools_to_openai(options);
220 if let Some(tools) = tools {
221 body.insert("tools".into(), tools);
222 }
223 if let Some(choice) = tool_choice {
224 body.insert("tool_choice".into(), choice);
225 }
226 if stream {
227 body.insert("stream".into(), json!(true));
228 body.insert("stream_options".into(), json!({ "include_usage": true }));
229 }
230 Value::Object(body)
231 }
232
233 async fn post(&self, body: &Value) -> Result<reqwest::Response> {
234 let url = format!(
235 "{}/chat/completions",
236 self.inner.base_url.trim_end_matches('/')
237 );
238 let mut req = self
239 .inner
240 .http
241 .post(&url)
242 .bearer_auth(&self.inner.api_key)
243 .json(body);
244 if let Some(org) = &self.inner.organization {
245 req = req.header("OpenAI-Organization", org);
246 }
247 let resp = req
248 .send()
249 .await
250 .map_err(|e| Error::service(format!("request failed: {e}")))?;
251 if !resp.status().is_success() {
252 let status = resp.status();
253 let retry_after = parse_retry_after(resp.headers());
254 let text = resp.text().await.unwrap_or_default();
255 return Err(classify_service_error(
256 status.as_u16(),
257 &text,
258 format!("OpenAI API error {status}: {text}"),
259 retry_after,
260 ));
261 }
262 Ok(resp)
263 }
264
265 pub fn model(&self) -> &str {
267 &self.inner.model
268 }
269}
270
271#[async_trait::async_trait]
272impl ChatClient for OpenAIChatCompletionClient {
273 async fn get_response(
274 &self,
275 messages: Vec<Message>,
276 options: ChatOptions,
277 ) -> Result<ChatResponse> {
278 let body = self.build_body(&messages, &options, false);
279 let resp = self.post(&body).await?;
280 let value: Value = resp
281 .json()
282 .await
283 .map_err(|e| Error::service(format!("invalid response json: {e}")))?;
284 Ok(convert::parse_response(&value))
285 }
286
287 async fn get_streaming_response(
288 &self,
289 messages: Vec<Message>,
290 options: ChatOptions,
291 ) -> Result<ChatStream> {
292 let body = self.build_body(&messages, &options, true);
293 let resp = self.post(&body).await?;
294 Ok(parse_sse_stream(resp).boxed())
295 }
296
297 fn model(&self) -> Option<&str> {
298 Some(&self.inner.model)
299 }
300}
301
302type ByteStream =
303 std::pin::Pin<Box<dyn futures::Stream<Item = reqwest::Result<bytes::Bytes>> + Send>>;
304
305#[doc(hidden)]
310pub fn parse_sse_stream(
311 resp: reqwest::Response,
312) -> impl futures::Stream<Item = Result<ChatResponseUpdate>> + Send {
313 let byte_stream: ByteStream = Box::pin(resp.bytes_stream());
314 futures::stream::unfold(
318 SseState {
319 byte_stream,
320 buffer: String::new(),
321 utf8: Utf8StreamDecoder::new(),
322 queued: VecDeque::new(),
323 tool_ids: HashMap::new(),
324 done: false,
325 },
326 |mut state| async move {
327 loop {
328 if let Some(update) = state.queued.pop_front() {
329 return Some((Ok(update), state));
330 }
331 if state.done {
332 return None;
333 }
334 match state.byte_stream.next().await {
335 Some(Ok(bytes)) => {
336 let decoded = state.utf8.push(&bytes);
337 state.buffer.push_str(&decoded);
338 while let Some(pos) = state.buffer.find('\n') {
339 let line = state.buffer[..pos].trim().to_string();
340 state.buffer.drain(..=pos);
341 if let Some(data) = line.strip_prefix("data:") {
342 let data = data.trim();
343 if data == "[DONE]" {
344 return drain_or_end(state);
345 }
346 if let Ok(value) = serde_json::from_str::<Value>(data) {
347 if let Some(err) = value.get("error") {
351 let msg = err
352 .get("message")
353 .and_then(Value::as_str)
354 .unwrap_or("unknown stream error")
355 .to_string();
356 state.done = true;
357 return Some((Err(Error::service(msg)), state));
358 }
359 if let Some(update) = parse_delta(&value, &mut state.tool_ids) {
360 state.queued.push_back(update);
361 }
362 }
363 }
364 }
365 }
366 Some(Err(e)) => {
367 state.done = true;
368 return Some((Err(Error::service(format!("stream error: {e}"))), state));
369 }
370 None => return drain_or_end(state),
371 }
372 }
373 },
374 )
375}
376
377struct SseState {
379 byte_stream: ByteStream,
380 buffer: String,
381 utf8: Utf8StreamDecoder,
382 queued: VecDeque<ChatResponseUpdate>,
383 tool_ids: HashMap<i64, String>,
384 done: bool,
385}
386
387fn drain_or_end(mut state: SseState) -> Option<(Result<ChatResponseUpdate>, SseState)> {
388 match state.queued.pop_front() {
389 Some(update) => {
390 state.done = true;
391 Some((Ok(update), state))
392 }
393 None => None,
394 }
395}
396
397fn parse_delta(value: &Value, tool_ids: &mut HashMap<i64, String>) -> Option<ChatResponseUpdate> {
400 let mut update = ChatResponseUpdate {
401 response_id: value.get("id").and_then(Value::as_str).map(String::from),
402 model: value.get("model").and_then(Value::as_str).map(String::from),
403 ..Default::default()
404 };
405
406 let mut contents: Vec<Content> = Vec::new();
407
408 if let Some(choice) = value
409 .get("choices")
410 .and_then(|c| c.as_array())
411 .and_then(|a| a.first())
412 {
413 if let Some(delta) = choice.get("delta") {
414 if let Some(r) = delta.get("role").and_then(Value::as_str) {
415 update.role = Some(Role::new(r));
416 }
417 if let Some(text) = delta.get("content").and_then(Value::as_str) {
418 if !text.is_empty() {
419 contents.push(Content::Text(TextContent::new(text)));
420 }
421 }
422 if let Some(calls) = delta.get("tool_calls").and_then(Value::as_array) {
423 for call in calls {
424 let index = call.get("index").and_then(Value::as_i64).unwrap_or(0);
425 let chunk_id = call.get("id").and_then(Value::as_str).unwrap_or_default();
426 let id = if chunk_id.is_empty() {
429 tool_ids.get(&index).cloned().unwrap_or_default()
430 } else {
431 tool_ids.insert(index, chunk_id.to_string());
432 chunk_id.to_string()
433 };
434 let func = call.get("function");
435 let name = func
436 .and_then(|f| f.get("name"))
437 .and_then(Value::as_str)
438 .unwrap_or_default()
439 .to_string();
440 let args = func
441 .and_then(|f| f.get("arguments"))
442 .and_then(Value::as_str)
443 .unwrap_or_default()
444 .to_string();
445 contents.push(Content::FunctionCall(FunctionCallContent::new(
446 id,
447 name,
448 Some(FunctionArguments::Raw(args)),
449 )));
450 }
451 }
452 }
453 if let Some(fr) = choice.get("finish_reason").and_then(Value::as_str) {
454 update.finish_reason = Some(FinishReason::new(fr));
455 }
456 }
457
458 if let Some(usage) = value.get("usage").filter(|u| u.is_object()) {
462 contents.push(Content::Usage(UsageContent {
463 details: convert::parse_usage(usage),
464 }));
465 }
466
467 if update.role.is_none() {
468 update.role = Some(Role::assistant());
469 }
470 update.contents = contents;
471 Some(update)
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 #[test]
485 fn classifies_401_and_403_as_invalid_auth() {
486 for status in [401, 403] {
487 let err = classify_service_error(status, "", format!("err {status}"), None);
488 assert!(
489 matches!(err, Error::ServiceInvalidAuth { .. }),
490 "status {status}: {err:?}"
491 );
492 }
493 }
494
495 #[test]
496 fn classifies_400_404_422_as_invalid_request_by_default() {
497 for status in [400, 404, 422] {
498 let body = r#"{"error":{"message":"nope","type":"invalid_request_error"}}"#;
499 let err = classify_service_error(status, body, format!("err {status}"), None);
500 assert!(
501 matches!(err, Error::ServiceInvalidRequest { .. }),
502 "status {status}: {err:?}"
503 );
504 }
505 }
506
507 #[test]
508 fn classifies_400_with_content_filter_code_as_content_filter() {
509 let body = r#"{"error":{"message":"flagged","type":"invalid_request_error","code":"content_filter"}}"#;
511 let err = classify_service_error(400, body, "err", None);
512 assert!(matches!(err, Error::ServiceContentFilter { .. }), "{err:?}");
513 }
514
515 #[test]
516 fn classifies_azure_openai_content_filter_shape_with_innererror() {
517 let body = r#"{"error":{"message":"The response was filtered","code":"content_filter","innererror":{"code":"ResponsibleAIPolicyViolation"}}}"#;
524 let err = classify_service_error(400, body, "err", None);
525 assert!(matches!(err, Error::ServiceContentFilter { .. }), "{err:?}");
526
527 let nested_only =
528 r#"{"error":{"message":"filtered","innererror":{"code":"content_filter"}}}"#;
529 let err = classify_service_error(400, nested_only, "err", None);
530 assert!(matches!(err, Error::ServiceContentFilter { .. }), "{err:?}");
531 }
532
533 #[test]
534 fn non_json_or_unmarked_body_does_not_trigger_content_filter() {
535 let err = classify_service_error(400, "not json", "err", None);
536 assert!(
537 matches!(err, Error::ServiceInvalidRequest { .. }),
538 "{err:?}"
539 );
540
541 let err = classify_service_error(400, r#"{"error":{"code":"other"}}"#, "err", None);
542 assert!(
543 matches!(err, Error::ServiceInvalidRequest { .. }),
544 "{err:?}"
545 );
546 }
547
548 #[test]
549 fn leaves_retryable_statuses_as_service_status() {
550 for status in [408, 429, 500, 503] {
553 let err = classify_service_error(status, "", format!("err {status}"), Some(2.0));
554 assert_eq!(err.status(), Some(status), "{err:?}");
555 assert_eq!(err.retry_after(), Some(2.0), "{err:?}");
556 }
557 }
558
559 #[test]
560 fn unclassified_4xx_stays_service_status() {
561 let err = classify_service_error(409, "", "err", None);
565 assert_eq!(err.status(), Some(409), "{err:?}");
566 }
567
568 #[test]
569 fn message_text_is_preserved_verbatim() {
570 let err = classify_service_error(401, "", "OpenAI API error 401: unauthorized", None);
571 assert_eq!(
572 err.to_string(),
573 "service error: OpenAI API error 401: unauthorized"
574 );
575 }
576}