1use std::collections::HashMap;
2use std::sync::Arc;
3
4use async_stream::try_stream;
5use async_trait::async_trait;
6use base64::{Engine as _, engine::general_purpose};
7use eventsource_stream::Eventsource;
8use futures::stream::StreamExt;
9use reqwest::{Client, Url};
10use serde::Deserialize;
11use serde_json::{Map, Value, json};
12use uuid::Uuid;
13
14use crate::json_schema::transform_openai_schema;
15use crate::messages::{
16 BinaryContent, ModelMessage, ModelRequestPart, ModelResponse, ModelResponsePart,
17 ProviderItemPart, TextPart, ToolCallPart, UserContent,
18};
19use crate::model::{
20 Model, ModelError, ModelRequestParameters, ModelSettings, ModelStream, OutputMode, StreamChunk,
21};
22use crate::providers::{Provider, ProviderError};
23use crate::usage::RequestUsage;
24
25fn map_reqwest_error(label: &str, error: reqwest::Error) -> ModelError {
26 if error.is_timeout() {
27 return ModelError::Timeout;
28 }
29 if error.is_connect() {
30 return ModelError::Transport(format!("{label} connect error: {error}"));
31 }
32 ModelError::Transport(format!("{label} request failed: {error}"))
33}
34
35fn truncate_error_body(body: &str) -> String {
36 const LIMIT: usize = 2000;
37 let trimmed = body.trim();
38 if trimmed.is_empty() {
39 return String::new();
40 }
41 if trimmed.chars().count() <= LIMIT {
42 return trimmed.to_string();
43 }
44 let truncated: String = trimmed.chars().take(LIMIT).collect();
45 format!("{truncated}...[truncated]")
46}
47
48fn join_path(base: &Url, path: &str) -> Result<Url, ModelError> {
49 let mut url = base.clone();
50 let base_path = url.path().trim_end_matches('/');
51 let path = path.trim_start_matches('/');
52 let new_path = if base_path.is_empty() || base_path == "/" {
53 format!("/{path}")
54 } else {
55 format!("{base_path}/{path}")
56 };
57 url.set_path(&new_path);
58 Ok(url)
59}
60
61fn normalize_tool_call_id(id: Option<String>) -> String {
62 match id {
63 Some(value) if !value.trim().is_empty() => value,
64 _ => format!("call_{}", Uuid::new_v4().simple()),
65 }
66}
67
68fn normalize_tool_call_id_str(id: &str) -> String {
69 if id.trim().is_empty() {
70 format!("call_{}", Uuid::new_v4().simple())
71 } else {
72 id.to_string()
73 }
74}
75
76fn tool_return_content(value: &Value) -> String {
77 match value {
78 Value::String(value) => value.clone(),
79 _ => serde_json::to_string(value).unwrap_or_else(|_| value.to_string()),
80 }
81}
82
83fn tool_call_arguments(value: &Value) -> String {
84 match value {
85 Value::String(value) => value.clone(),
86 _ => serde_json::to_string(value).unwrap_or_else(|_| value.to_string()),
87 }
88}
89
90fn is_text_like_media_type(media_type: &str) -> bool {
91 media_type.starts_with("text/")
92 || matches!(
93 media_type,
94 "application/json"
95 | "application/xml"
96 | "application/xhtml+xml"
97 | "application/javascript"
98 | "application/x-www-form-urlencoded"
99 )
100}
101
102fn audio_format_from_media_type(media_type: &str) -> Option<&'static str> {
103 match media_type {
104 "audio/wav" | "audio/x-wav" => Some("wav"),
105 "audio/mpeg" | "audio/mp3" => Some("mp3"),
106 "audio/ogg" | "audio/ogg;codecs=opus" => Some("ogg"),
107 "audio/flac" => Some("flac"),
108 "audio/aiff" => Some("aiff"),
109 "audio/aac" => Some("aac"),
110 _ => None,
111 }
112}
113
114fn parse_data_url_base64(url: &str) -> Option<(String, String)> {
115 let data_url = url.strip_prefix("data:")?;
116 let (meta, data) = data_url.split_once(',')?;
117 let (media_type, encoding) = meta.split_once(';')?;
118 if encoding != "base64" || media_type.trim().is_empty() {
119 return None;
120 }
121 Some((media_type.to_string(), data.to_string()))
122}
123
124fn normalize_stream_tool_call_id(id: Option<String>, index: Option<usize>) -> String {
125 if let Some(value) = id.filter(|value| !value.trim().is_empty()) {
126 value
127 } else if let Some(index) = index {
128 format!("call_{index}")
129 } else {
130 normalize_tool_call_id(None)
131 }
132}
133
134fn contains_audio(messages: &[ModelMessage]) -> bool {
135 for message in messages {
136 if let ModelMessage::Request(req) = message {
137 for part in &req.parts {
138 if let ModelRequestPart::UserPrompt(prompt) = part {
139 for item in &prompt.content {
140 match item {
141 UserContent::Audio(_) => return true,
142 UserContent::Binary(binary) => {
143 if binary.media_type.starts_with("audio/") {
144 return true;
145 }
146 }
147 _ => {}
148 }
149 }
150 }
151 }
152 }
153 }
154 false
155}
156
157fn is_responses_only_model(model: &str) -> bool {
158 let lowered = model.to_lowercase();
159 lowered.starts_with("gpt-5")
160 || lowered.starts_with("gpt-4.1")
161 || lowered.starts_with("o1")
162 || lowered.starts_with("o3")
163}
164
165fn prefers_responses(model: &str) -> bool {
166 let lowered = model.to_lowercase();
167 is_responses_only_model(model)
168 || lowered.starts_with("gpt-4o")
169 || lowered.starts_with("gpt-4.1")
170 || lowered.starts_with("o1")
171 || lowered.starts_with("o3")
172}
173
174#[derive(Clone, Debug)]
175pub(crate) struct OpenAIChatCapabilities {
176 pub(crate) supports_response_format: bool,
177 pub(crate) supports_parallel_tool_calls: bool,
178 pub(crate) reject_binary_images: bool,
179}
180
181impl Default for OpenAIChatCapabilities {
182 fn default() -> Self {
183 Self {
184 supports_response_format: true,
185 supports_parallel_tool_calls: true,
186 reject_binary_images: false,
187 }
188 }
189}
190
191#[derive(Clone, Debug)]
192pub struct OpenAIProvider {
193 api_key: String,
194 base_url: Url,
195}
196
197impl OpenAIProvider {
198 pub fn new(
199 api_key: impl Into<String>,
200 base_url: impl AsRef<str>,
201 ) -> Result<Self, ProviderError> {
202 let url = Url::parse(base_url.as_ref())
203 .map_err(|_| ProviderError::InvalidModel(base_url.as_ref().to_string()))?;
204 Ok(Self {
205 api_key: api_key.into(),
206 base_url: url,
207 })
208 }
209
210 pub fn from_env() -> Result<Self, ProviderError> {
211 let api_key = std::env::var("OPENAI_API_KEY")
212 .map_err(|_| ProviderError::MissingApiKey("openai".to_string()))?;
213 Self::new(api_key, "https://api.openai.com/v1")
214 }
215
216 pub fn with_base_url(mut self, base_url: impl AsRef<str>) -> Result<Self, ProviderError> {
217 self.base_url = Url::parse(base_url.as_ref())
218 .map_err(|_| ProviderError::InvalidModel(base_url.as_ref().to_string()))?;
219 Ok(self)
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use base64::engine::general_purpose::STANDARD;
227 use serde_json::{Value, json};
228 use std::path::PathBuf;
229
230 use crate::messages::{
231 ModelMessage, ModelRequest, ModelRequestPart, ModelResponse, ModelResponsePart,
232 ProviderItemPart, TextPart, ToolCallPart, ToolReturnPart,
233 };
234
235 fn fixture_bytes(name: &str) -> Vec<u8> {
236 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
237 .join("tests")
238 .join("fixtures")
239 .join(name);
240 std::fs::read(path).expect("fixture read")
241 }
242
243 #[test]
244 fn convert_user_content_handles_binary_media() {
245 let model = OpenAIChatModel::new(
246 "gpt-4o-mini",
247 "test-key".to_string(),
248 Url::parse("https://example.com/").expect("valid url"),
249 None,
250 );
251
252 let image_bytes = fixture_bytes("fixture.jpg");
253 let audio_bytes = fixture_bytes("fixture.m4a");
254 let pdf_bytes = fixture_bytes("fixture.pdf");
255
256 let content = vec![
257 UserContent::Binary(BinaryContent {
258 data: image_bytes.clone(),
259 media_type: "image/jpeg".to_string(),
260 }),
261 UserContent::Binary(BinaryContent {
262 data: audio_bytes.clone(),
263 media_type: "audio/aac".to_string(),
264 }),
265 UserContent::Binary(BinaryContent {
266 data: pdf_bytes.clone(),
267 media_type: "application/pdf".to_string(),
268 }),
269 ];
270
271 let value = model
272 .convert_user_content(&content)
273 .expect("convert user content");
274 let parts = value.as_array().expect("parts array");
275 assert_eq!(parts.len(), 3);
276
277 let image = &parts[0];
278 assert_eq!(
279 image.get("type"),
280 Some(&Value::String("image_url".to_string()))
281 );
282 let image_url = image
283 .get("image_url")
284 .and_then(|value| value.get("url"))
285 .and_then(|value| value.as_str())
286 .expect("image url");
287 let expected_image = format!("data:image/jpeg;base64,{}", STANDARD.encode(&image_bytes));
288 assert_eq!(image_url, expected_image);
289
290 let audio = &parts[1];
291 assert_eq!(
292 audio.get("type"),
293 Some(&Value::String("input_audio".to_string()))
294 );
295 let audio_input = audio.get("input_audio").expect("input_audio");
296 assert_eq!(
297 audio_input.get("format"),
298 Some(&Value::String("aac".to_string()))
299 );
300 let audio_data = audio_input
301 .get("data")
302 .and_then(|value| value.as_str())
303 .expect("audio data");
304 assert_eq!(audio_data, STANDARD.encode(&audio_bytes));
305
306 let pdf = &parts[2];
307 assert_eq!(pdf.get("type"), Some(&Value::String("text".to_string())));
308 let pdf_text = pdf
309 .get("text")
310 .and_then(|value| value.as_str())
311 .expect("pdf text");
312 let expected_text = format!("[binary content: {} bytes]", pdf_bytes.len());
313 assert_eq!(pdf_text, expected_text);
314 }
315
316 #[test]
317 fn make_messages_replays_tool_calls() {
318 let model = OpenAIChatModel::new(
319 "gpt-4o-mini",
320 "test-key".to_string(),
321 Url::parse("https://example.com/").expect("valid url"),
322 None,
323 );
324
325 let messages = vec![
326 ModelMessage::Response(ModelResponse {
327 parts: vec![ModelResponsePart::ToolCall(ToolCallPart {
328 id: "call-1".to_string(),
329 name: "get_data".to_string(),
330 arguments: json!({"a": 1}),
331 })],
332 usage: None,
333 model_name: None,
334 finish_reason: None,
335 }),
336 ModelMessage::Request(ModelRequest {
337 parts: vec![ModelRequestPart::ToolReturn(ToolReturnPart {
338 tool_name: "get_data".to_string(),
339 tool_call_id: "call-1".to_string(),
340 content: json!({"ok": true}),
341 })],
342 instructions: None,
343 }),
344 ];
345
346 let out = model.make_messages(&messages).expect("make messages");
347 assert_eq!(out.len(), 2);
348
349 let assistant = out[0].as_object().expect("assistant message");
350 assert_eq!(
351 assistant.get("role"),
352 Some(&Value::String("assistant".to_string()))
353 );
354 assert_eq!(assistant.get("content"), Some(&Value::Null));
355 let tool_calls = assistant
356 .get("tool_calls")
357 .and_then(|value| value.as_array())
358 .expect("tool_calls");
359 assert_eq!(tool_calls.len(), 1);
360 let call = &tool_calls[0];
361 assert_eq!(call.get("id"), Some(&Value::String("call-1".to_string())));
362 let function = call.get("function").expect("function");
363 assert_eq!(
364 function.get("name"),
365 Some(&Value::String("get_data".to_string()))
366 );
367 assert_eq!(
368 function.get("arguments"),
369 Some(&Value::String("{\"a\":1}".to_string()))
370 );
371
372 let tool = out[1].as_object().expect("tool message");
373 assert_eq!(tool.get("role"), Some(&Value::String("tool".to_string())));
374 assert_eq!(
375 tool.get("tool_call_id"),
376 Some(&Value::String("call-1".to_string()))
377 );
378 assert_eq!(
379 tool.get("content"),
380 Some(&Value::String("{\"ok\":true}".to_string()))
381 );
382 }
383
384 #[test]
385 fn responses_replays_tool_calls() {
386 let model = OpenAIResponsesModel::new(
387 "gpt-5-mini",
388 "test-key".to_string(),
389 Url::parse("https://example.com/").expect("valid url"),
390 None,
391 );
392
393 let messages = vec![
394 ModelMessage::Response(ModelResponse {
395 parts: vec![ModelResponsePart::ToolCall(ToolCallPart {
396 id: "call-1".to_string(),
397 name: "get_data".to_string(),
398 arguments: json!({"a": 1}),
399 })],
400 usage: None,
401 model_name: None,
402 finish_reason: None,
403 }),
404 ModelMessage::Request(ModelRequest {
405 parts: vec![ModelRequestPart::ToolReturn(ToolReturnPart {
406 tool_name: "get_data".to_string(),
407 tool_call_id: "call-1".to_string(),
408 content: json!({"ok": true}),
409 })],
410 instructions: None,
411 }),
412 ];
413
414 let out = model
415 .make_input_messages(&messages)
416 .expect("make input messages");
417 assert_eq!(out.len(), 2);
418
419 let call = out[0].as_object().expect("function call item");
420 assert_eq!(
421 call.get("type"),
422 Some(&Value::String("function_call".to_string()))
423 );
424 assert_eq!(
425 call.get("call_id"),
426 Some(&Value::String("call-1".to_string()))
427 );
428 assert_eq!(
429 call.get("name"),
430 Some(&Value::String("get_data".to_string()))
431 );
432 assert_eq!(
433 call.get("arguments"),
434 Some(&Value::String("{\"a\":1}".to_string()))
435 );
436
437 let output = out[1].as_object().expect("function call output");
438 assert_eq!(
439 output.get("type"),
440 Some(&Value::String("function_call_output".to_string()))
441 );
442 assert_eq!(
443 output.get("call_id"),
444 Some(&Value::String("call-1".to_string()))
445 );
446 assert_eq!(
447 output.get("output"),
448 Some(&Value::String("{\"ok\":true}".to_string()))
449 );
450 }
451
452 #[test]
453 fn responses_replays_provider_items() {
454 let model = OpenAIResponsesModel::new(
455 "gpt-5-mini",
456 "test-key".to_string(),
457 Url::parse("https://example.com/").expect("valid url"),
458 None,
459 );
460
461 let raw_item = json!({
462 "type": "reasoning",
463 "summary": "ok"
464 });
465
466 let messages = vec![ModelMessage::Response(ModelResponse {
467 parts: vec![
468 ModelResponsePart::ProviderItem(ProviderItemPart {
469 provider: "openai_responses".to_string(),
470 payload: raw_item.clone(),
471 }),
472 ModelResponsePart::Text(TextPart {
473 content: "ignored".to_string(),
474 }),
475 ],
476 usage: None,
477 model_name: None,
478 finish_reason: None,
479 })];
480
481 let out = model
482 .make_input_messages(&messages)
483 .expect("make input messages");
484 assert_eq!(out.len(), 1);
485 assert_eq!(out[0], raw_item);
486 }
487
488 #[test]
489 fn unified_model_streaming_prefers_chat_when_available() {
490 let model = OpenAIUnifiedModel::new(
491 "gpt-4o-mini",
492 "test-key".to_string(),
493 Url::parse("https://example.com/").expect("valid url"),
494 None,
495 );
496
497 let mode = model.select_api(&[], true).expect("select api for stream");
498 assert!(matches!(mode, OpenAIApiMode::Chat));
499 }
500
501 #[test]
502 fn unified_model_streaming_errors_for_responses_only() {
503 let model = OpenAIUnifiedModel::new(
504 "gpt-5-mini",
505 "test-key".to_string(),
506 Url::parse("https://example.com/").expect("valid url"),
507 None,
508 );
509
510 let err = model.select_api(&[], true).expect_err("streaming error");
511 assert!(matches!(err, ModelError::Unsupported(_)));
512 }
513}
514
515impl Provider for OpenAIProvider {
516 fn name(&self) -> &str {
517 "openai"
518 }
519
520 fn model(&self, model: &str, settings: Option<ModelSettings>) -> Arc<dyn Model> {
521 Arc::new(OpenAIUnifiedModel::new(
522 model,
523 self.api_key.clone(),
524 self.base_url.clone(),
525 settings,
526 ))
527 }
528}
529
530#[derive(Clone, Debug)]
531pub struct OpenAIChatModel {
532 model: String,
533 api_key: String,
534 base_url: Url,
535 client: Client,
536 default_settings: Option<ModelSettings>,
537 capabilities: OpenAIChatCapabilities,
538}
539
540impl OpenAIChatModel {
541 pub fn new(
542 model: impl Into<String>,
543 api_key: String,
544 base_url: Url,
545 settings: Option<ModelSettings>,
546 ) -> Self {
547 Self::new_with_capabilities(
548 model,
549 api_key,
550 base_url,
551 settings,
552 OpenAIChatCapabilities::default(),
553 )
554 }
555
556 pub(crate) fn new_with_capabilities(
557 model: impl Into<String>,
558 api_key: String,
559 base_url: Url,
560 settings: Option<ModelSettings>,
561 capabilities: OpenAIChatCapabilities,
562 ) -> Self {
563 Self {
564 model: model.into(),
565 api_key,
566 base_url,
567 client: Client::new(),
568 default_settings: settings,
569 capabilities,
570 }
571 }
572
573 fn endpoint(&self) -> Result<Url, ModelError> {
574 join_path(&self.base_url, "chat/completions")
575 }
576
577 fn make_messages(&self, messages: &[ModelMessage]) -> Result<Vec<Value>, ModelError> {
578 let mut out = Vec::new();
579 for message in messages {
580 match message {
581 ModelMessage::Request(req) => {
582 if let Some(instructions) = req
583 .instructions
584 .as_ref()
585 .filter(|value| !value.trim().is_empty())
586 {
587 out.push(json!({"role": "system", "content": instructions}));
588 }
589 for part in &req.parts {
590 match part {
591 ModelRequestPart::SystemPrompt(prompt) => {
592 out.push(json!({"role": "system", "content": prompt.content}))
593 }
594 ModelRequestPart::UserPrompt(prompt) => {
595 let content = self.convert_user_content(&prompt.content)?;
596 out.push(json!({"role": "user", "content": content}))
597 }
598 ModelRequestPart::ToolReturn(tool_return) => {
599 let content = tool_return_content(&tool_return.content);
600 out.push(json!({
601 "role": "tool",
602 "tool_call_id": normalize_tool_call_id_str(&tool_return.tool_call_id),
603 "content": content,
604 }))
605 }
606 ModelRequestPart::RetryPrompt(retry) => {
607 if retry.tool_name.is_some() {
608 out.push(json!({
609 "role": "tool",
610 "tool_call_id": normalize_tool_call_id(retry.tool_call_id.clone()),
611 "content": retry.content,
612 }));
613 } else {
614 out.push(json!({
615 "role": "user",
616 "content": retry.content,
617 }));
618 }
619 }
620 }
621 }
622 }
623 ModelMessage::Response(res) => {
624 let text = res.text();
625 let tool_calls = res.tool_calls();
626
627 if text.is_none() && tool_calls.is_empty() {
628 continue;
629 }
630
631 let mut msg = Map::new();
632 msg.insert("role".to_string(), Value::String("assistant".to_string()));
633
634 if let Some(text) = text {
635 msg.insert("content".to_string(), Value::String(text));
636 } else if !tool_calls.is_empty() {
637 msg.insert("content".to_string(), Value::Null);
638 }
639
640 if !tool_calls.is_empty() {
641 let calls = tool_calls
642 .into_iter()
643 .map(|call| {
644 let args = tool_call_arguments(&call.arguments);
645 json!({
646 "id": normalize_tool_call_id_str(&call.id),
647 "type": "function",
648 "function": {
649 "name": call.name,
650 "arguments": args,
651 }
652 })
653 })
654 .collect::<Vec<_>>();
655 msg.insert("tool_calls".to_string(), Value::Array(calls));
656 }
657
658 out.push(Value::Object(msg));
659 }
660 }
661 }
662 Ok(out)
663 }
664
665 fn convert_user_content(&self, content: &[UserContent]) -> Result<Value, ModelError> {
666 let mut parts = Vec::new();
667 for item in content {
668 match item {
669 UserContent::Text(text) => parts.push(json!({"type": "text", "text": text})),
670 UserContent::Image(image) => parts.push(json!({
671 "type": "image_url",
672 "image_url": {"url": image.url}
673 })),
674 UserContent::Binary(BinaryContent { data, media_type }) => {
675 if media_type.starts_with("image/") {
676 if self.capabilities.reject_binary_images {
677 return Err(ModelError::Unsupported(
678 "binary image inputs are not supported; provide an image URL"
679 .to_string(),
680 ));
681 }
682 let encoded = general_purpose::STANDARD.encode(data);
683 let data_url = format!("data:{};base64,{}", media_type, encoded);
684 parts.push(json!({
685 "type": "image_url",
686 "image_url": {"url": data_url}
687 }))
688 } else if media_type.starts_with("audio/") {
689 if let Some(format) = audio_format_from_media_type(media_type) {
690 let encoded = general_purpose::STANDARD.encode(data);
691 parts.push(json!({
692 "type": "input_audio",
693 "input_audio": {
694 "data": encoded,
695 "format": format
696 }
697 }))
698 } else {
699 parts.push(json!({
700 "type": "text",
701 "text": format!("[audio content: {} bytes]", data.len())
702 }))
703 }
704 } else if is_text_like_media_type(media_type) {
705 match std::str::from_utf8(data) {
706 Ok(text) => parts.push(json!({"type": "text", "text": text})),
707 Err(_) => parts.push(json!({
708 "type": "text",
709 "text": format!("[binary content: {} bytes]", data.len())
710 })),
711 }
712 } else {
713 parts.push(json!({
714 "type": "text",
715 "text": format!("[binary content: {} bytes]", data.len())
716 }))
717 }
718 }
719 UserContent::Audio(audio) => {
720 if let Some((media_type, data)) = parse_data_url_base64(&audio.url)
721 && let Some(format) = audio_format_from_media_type(&media_type)
722 {
723 parts.push(json!({
724 "type": "input_audio",
725 "input_audio": {
726 "data": data,
727 "format": format
728 }
729 }))
730 } else {
731 parts.push(json!({
732 "type": "text",
733 "text": format!("[audio: {}]", audio.url)
734 }))
735 }
736 }
737 UserContent::Video(video) => parts.push(json!({
738 "type": "text",
739 "text": format!("[video: {}]", video.url)
740 })),
741 UserContent::Document(doc) => {
742 if let Some((media_type, data)) = parse_data_url_base64(&doc.url)
743 && is_text_like_media_type(&media_type)
744 {
745 match general_purpose::STANDARD.decode(data.as_bytes()) {
746 Ok(bytes) => match String::from_utf8(bytes) {
747 Ok(text) => parts.push(json!({"type": "text", "text": text})),
748 Err(_) => parts.push(json!({
749 "type": "text",
750 "text": format!("[document: {}]", doc.url)
751 })),
752 },
753 Err(_) => parts.push(json!({
754 "type": "text",
755 "text": format!("[document: {}]", doc.url)
756 })),
757 }
758 } else {
759 parts.push(json!({
760 "type": "text",
761 "text": format!("[document: {}]", doc.url)
762 }))
763 }
764 }
765 }
766 }
767
768 Ok(Value::Array(parts))
769 }
770
771 fn build_body(
772 &self,
773 messages: &[ModelMessage],
774 params: &ModelRequestParameters,
775 stream: bool,
776 ) -> Result<Value, ModelError> {
777 let mut body = Map::new();
778 body.insert("model".to_string(), Value::String(self.model.clone()));
779 body.insert(
780 "messages".to_string(),
781 Value::Array(self.make_messages(messages)?),
782 );
783
784 if !params.function_tools.is_empty() {
785 let tools = params
786 .function_tools
787 .iter()
788 .map(|tool| {
789 let (schema, _strict_ok) =
790 transform_openai_schema(&tool.parameters_json_schema, None);
791 json!({
792 "type": "function",
793 "function": {
794 "name": tool.name,
795 "description": tool.description,
796 "parameters": schema,
797 }
798 })
799 })
800 .collect();
801 body.insert("tools".to_string(), Value::Array(tools));
802 body.insert("tool_choice".to_string(), Value::String("auto".to_string()));
803 if self.capabilities.supports_parallel_tool_calls
804 && params.function_tools.iter().any(|tool| tool.sequential)
805 {
806 body.insert("parallel_tool_calls".to_string(), Value::Bool(false));
807 }
808 }
809
810 if params.output_mode == OutputMode::JsonSchema
811 && let Some(schema) = params.output_schema.clone()
812 && self.capabilities.supports_response_format
813 {
814 let strict = !params.allow_text_output;
815 let (schema, _strict_ok) = transform_openai_schema(&schema, Some(strict));
816 body.insert(
817 "response_format".to_string(),
818 json!({
819 "type": "json_schema",
820 "json_schema": {
821 "name": "output",
822 "schema": schema,
823 "strict": strict,
824 }
825 }),
826 );
827 }
828
829 if stream {
830 body.insert("stream".to_string(), Value::Bool(true));
831 body.insert("stream_options".to_string(), json!({"include_usage": true}));
832 }
833
834 if let Some(settings) = &self.default_settings {
835 for (key, value) in settings {
836 body.entry(key.clone()).or_insert(value.clone());
837 }
838 }
839
840 Ok(Value::Object(body))
841 }
842
843 fn parse_tool_call(tool_call: &OpenAIToolCall) -> ToolCallPart {
844 let args = tool_call
845 .function
846 .arguments
847 .as_ref()
848 .and_then(|arg| serde_json::from_str::<Value>(arg).ok())
849 .unwrap_or_else(|| {
850 tool_call
851 .function
852 .arguments
853 .clone()
854 .map(Value::String)
855 .unwrap_or_else(|| Value::Object(Map::new()))
856 });
857
858 ToolCallPart {
859 id: normalize_tool_call_id(tool_call.id.clone()),
860 name: tool_call
861 .function
862 .name
863 .clone()
864 .unwrap_or_else(|| "tool".to_string()),
865 arguments: args,
866 }
867 }
868}
869
870#[async_trait]
871impl Model for OpenAIChatModel {
872 fn name(&self) -> &str {
873 &self.model
874 }
875
876 async fn request(
877 &self,
878 messages: &[ModelMessage],
879 settings: Option<&ModelSettings>,
880 params: &ModelRequestParameters,
881 ) -> Result<ModelResponse, ModelError> {
882 tracing::debug!(
883 model = %self.model,
884 tool_count = params.function_tools.len(),
885 output_schema = params.output_schema.is_some(),
886 "OpenAI chat request"
887 );
888 let mut body = self.build_body(messages, params, false)?;
889 if let Some(settings) = settings
890 && let Value::Object(map) = &mut body
891 {
892 for (key, value) in settings {
893 map.insert(key.clone(), value.clone());
894 }
895 }
896
897 let response = self
898 .client
899 .post(self.endpoint()?)
900 .bearer_auth(&self.api_key)
901 .json(&body)
902 .send()
903 .await
904 .map_err(|e| map_reqwest_error("OpenAI", e))?;
905
906 let status = response.status();
907 if !status.is_success() {
908 let body = response.text().await.unwrap_or_default();
909 tracing::error!(
910 status = status.as_u16(),
911 model = %self.model,
912 body = %truncate_error_body(&body),
913 "OpenAI chat request failed"
914 );
915 return Err(ModelError::HttpStatus {
916 status: status.as_u16(),
917 });
918 }
919
920 let body: OpenAIChatResponse = response.json().await.map_err(|e| {
921 tracing::error!(error = %e, model = %self.model, "OpenAI response parse failed");
922 ModelError::Provider(format!("OpenAI response parse failed: {e}"))
923 })?;
924
925 let choice = body.choices.into_iter().next().ok_or_else(|| {
926 tracing::error!(model = %self.model, "OpenAI response missing choices");
927 ModelError::Provider("OpenAI response missing choices".to_string())
928 })?;
929
930 let mut parts = Vec::new();
931 if let Some(content) = choice.message.content {
932 parts.push(ModelResponsePart::Text(TextPart { content }));
933 }
934
935 if let Some(tool_calls) = choice.message.tool_calls {
936 for call in tool_calls {
937 parts.push(ModelResponsePart::ToolCall(Self::parse_tool_call(&call)));
938 }
939 } else if let Some(function_call) = choice.message.function_call {
940 parts.push(ModelResponsePart::ToolCall(ToolCallPart {
941 id: normalize_tool_call_id(None),
942 name: function_call.name.unwrap_or_else(|| "tool".to_string()),
943 arguments: function_call
944 .arguments
945 .as_ref()
946 .and_then(|arg| serde_json::from_str::<Value>(arg).ok())
947 .unwrap_or_else(|| {
948 function_call
949 .arguments
950 .clone()
951 .map(Value::String)
952 .unwrap_or_else(|| Value::Object(Map::new()))
953 }),
954 }));
955 }
956
957 let usage = body.usage.map(|usage| RequestUsage {
958 input_tokens: usage.prompt_tokens.unwrap_or(0),
959 output_tokens: usage.completion_tokens.unwrap_or(0),
960 ..Default::default()
961 });
962
963 Ok(ModelResponse {
964 parts,
965 usage,
966 model_name: Some(self.model.clone()),
967 finish_reason: choice.finish_reason,
968 })
969 }
970
971 async fn request_stream(
972 &self,
973 messages: &[ModelMessage],
974 settings: Option<&ModelSettings>,
975 params: &ModelRequestParameters,
976 ) -> Result<ModelStream, ModelError> {
977 tracing::debug!(
978 model = %self.model,
979 tool_count = params.function_tools.len(),
980 output_schema = params.output_schema.is_some(),
981 "OpenAI stream request"
982 );
983 let mut body = self.build_body(messages, params, true)?;
984 if let Some(settings) = settings
985 && let Value::Object(map) = &mut body
986 {
987 for (key, value) in settings {
988 map.insert(key.clone(), value.clone());
989 }
990 }
991
992 let response = self
993 .client
994 .post(self.endpoint()?)
995 .bearer_auth(&self.api_key)
996 .json(&body)
997 .send()
998 .await
999 .map_err(|e| map_reqwest_error("OpenAI stream", e))?;
1000
1001 let status = response.status();
1002 if !status.is_success() {
1003 let body = response.text().await.unwrap_or_default();
1004 tracing::error!(
1005 status = status.as_u16(),
1006 model = %self.model,
1007 body = %truncate_error_body(&body),
1008 "OpenAI stream request failed"
1009 );
1010 return Err(ModelError::HttpStatus {
1011 status: status.as_u16(),
1012 });
1013 }
1014
1015 let mut event_stream = response.bytes_stream().eventsource();
1016 let model_name = self.model.clone();
1017
1018 let s = try_stream! {
1019 let mut tool_accumulator: HashMap<String, ToolAccumulator> = HashMap::new();
1020 while let Some(event) = event_stream.next().await {
1021 let event = event.map_err(|e| {
1022 tracing::error!(error = %e, model = %model_name, "OpenAI stream error");
1023 ModelError::Provider(format!("OpenAI stream error: {e}"))
1024 })?;
1025 let data = event.data;
1026 if data.trim() == "[DONE]" {
1027 if !tool_accumulator.is_empty() {
1028 for (_id, acc) in tool_accumulator.drain() {
1029 let args = serde_json::from_str::<Value>(&acc.arguments)
1030 .unwrap_or_else(|_| Value::String(acc.arguments.clone()));
1031 yield StreamChunk {
1032 text_delta: None,
1033 tool_call: Some(ToolCallPart {
1034 id: acc.id.clone(),
1035 name: acc.name.unwrap_or_else(|| "tool".to_string()),
1036 arguments: args,
1037 }),
1038 finish_reason: None,
1039 usage: None,
1040 };
1041 }
1042 }
1043 break;
1044 }
1045
1046 let chunk: OpenAIChatStreamResponse = serde_json::from_str(&data)
1047 .map_err(|e| {
1048 tracing::error!(error = %e, model = %model_name, "OpenAI stream parse error");
1049 ModelError::Provider(format!("OpenAI stream parse error: {e}"))
1050 })?;
1051 if let Some(choice) = chunk.choices.into_iter().next() {
1052 if let Some(content) = choice.delta.content {
1053 yield StreamChunk {
1054 text_delta: Some(content),
1055 tool_call: None,
1056 finish_reason: None,
1057 usage: None,
1058 };
1059 }
1060
1061 if let Some(tool_calls) = choice.delta.tool_calls {
1062 for call in tool_calls {
1063 let id = normalize_stream_tool_call_id(call.id.clone(), call.index);
1064 let entry = tool_accumulator.entry(id.clone()).or_insert_with(|| ToolAccumulator {
1065 id,
1066 name: None,
1067 arguments: String::new(),
1068 });
1069 if let Some(name) = call.function.name {
1070 entry.name = Some(name);
1071 }
1072 if let Some(args) = call.function.arguments {
1073 entry.arguments.push_str(&args);
1074 }
1075 }
1076 }
1077
1078 if let Some(reason) = choice.finish_reason.clone() {
1079 if !tool_accumulator.is_empty() {
1080 for (_id, acc) in tool_accumulator.drain() {
1081 let args = serde_json::from_str::<Value>(&acc.arguments)
1082 .unwrap_or_else(|_| Value::String(acc.arguments.clone()));
1083 yield StreamChunk {
1084 text_delta: None,
1085 tool_call: Some(ToolCallPart {
1086 id: acc.id.clone(),
1087 name: acc.name.unwrap_or_else(|| "tool".to_string()),
1088 arguments: args,
1089 }),
1090 finish_reason: Some(reason.clone()),
1091 usage: None,
1092 };
1093 }
1094 }
1095 yield StreamChunk {
1096 text_delta: None,
1097 tool_call: None,
1098 finish_reason: Some(reason),
1099 usage: chunk.usage.map(|usage| RequestUsage {
1100 input_tokens: usage.prompt_tokens.unwrap_or(0),
1101 output_tokens: usage.completion_tokens.unwrap_or(0),
1102 ..Default::default()
1103 }),
1104 };
1105 }
1106 }
1107 }
1108 };
1109
1110 Ok(Box::pin(s))
1111 }
1112}
1113
1114#[derive(Debug, Deserialize)]
1115struct OpenAIChatResponse {
1116 choices: Vec<OpenAIChoice>,
1117 usage: Option<OpenAIUsage>,
1118}
1119
1120#[derive(Debug, Deserialize)]
1121struct OpenAIChoice {
1122 message: OpenAIMessage,
1123 finish_reason: Option<String>,
1124}
1125
1126#[derive(Debug, Deserialize)]
1127struct OpenAIMessage {
1128 content: Option<String>,
1129 tool_calls: Option<Vec<OpenAIToolCall>>,
1130 function_call: Option<OpenAIFunctionCall>,
1131}
1132
1133#[derive(Debug, Deserialize)]
1134struct OpenAIToolCall {
1135 id: Option<String>,
1136 function: OpenAIToolFunction,
1137}
1138
1139#[derive(Debug, Deserialize)]
1140struct OpenAIToolFunction {
1141 name: Option<String>,
1142 arguments: Option<String>,
1143}
1144
1145#[derive(Debug, Deserialize)]
1146struct OpenAIFunctionCall {
1147 name: Option<String>,
1148 arguments: Option<String>,
1149}
1150
1151#[derive(Debug, Deserialize)]
1152struct OpenAIUsage {
1153 prompt_tokens: Option<u64>,
1154 completion_tokens: Option<u64>,
1155}
1156
1157#[derive(Debug, Deserialize)]
1158struct OpenAIChatStreamResponse {
1159 choices: Vec<OpenAIChatStreamChoice>,
1160 usage: Option<OpenAIUsage>,
1161}
1162
1163#[derive(Debug, Deserialize)]
1164struct OpenAIChatStreamChoice {
1165 delta: OpenAIChatStreamDelta,
1166 finish_reason: Option<String>,
1167}
1168
1169#[derive(Debug, Deserialize)]
1170struct OpenAIChatStreamDelta {
1171 content: Option<String>,
1172 tool_calls: Option<Vec<OpenAIStreamToolCall>>,
1173}
1174
1175#[derive(Debug, Deserialize)]
1176struct OpenAIStreamToolCall {
1177 id: Option<String>,
1178 index: Option<usize>,
1179 function: OpenAIStreamToolFunction,
1180}
1181
1182#[derive(Debug, Deserialize)]
1183struct OpenAIStreamToolFunction {
1184 name: Option<String>,
1185 arguments: Option<String>,
1186}
1187
1188#[derive(Debug)]
1189struct ToolAccumulator {
1190 id: String,
1191 name: Option<String>,
1192 arguments: String,
1193}
1194
1195#[derive(Clone, Debug)]
1196pub struct OpenAIUnifiedModel {
1197 model: String,
1198 chat: OpenAIChatModel,
1199 responses: OpenAIResponsesModel,
1200 responses_only: bool,
1201 prefer_responses: bool,
1202}
1203
1204impl OpenAIUnifiedModel {
1205 pub fn new(
1206 model: impl Into<String>,
1207 api_key: String,
1208 base_url: Url,
1209 settings: Option<ModelSettings>,
1210 ) -> Self {
1211 let model = model.into();
1212 let responses_only = is_responses_only_model(&model);
1213 let prefer_responses = prefers_responses(&model);
1214 Self {
1215 chat: OpenAIChatModel::new(
1216 model.clone(),
1217 api_key.clone(),
1218 base_url.clone(),
1219 settings.clone(),
1220 ),
1221 responses: OpenAIResponsesModel::new(model.clone(), api_key, base_url, settings),
1222 model,
1223 responses_only,
1224 prefer_responses,
1225 }
1226 }
1227
1228 fn select_api(
1229 &self,
1230 messages: &[ModelMessage],
1231 stream: bool,
1232 ) -> Result<OpenAIApiMode, ModelError> {
1233 if contains_audio(messages) {
1234 if self.responses_only {
1235 return Err(ModelError::Unsupported(
1236 "OpenAI Responses API does not support audio input".to_string(),
1237 ));
1238 }
1239 return Ok(OpenAIApiMode::Chat);
1240 }
1241 if stream {
1242 if self.responses_only {
1243 return Err(ModelError::Unsupported(
1244 "streaming not supported for OpenAI Responses API".to_string(),
1245 ));
1246 }
1247 return Ok(OpenAIApiMode::Chat);
1248 }
1249 if self.prefer_responses || self.responses_only {
1250 Ok(OpenAIApiMode::Responses)
1251 } else {
1252 Ok(OpenAIApiMode::Chat)
1253 }
1254 }
1255}
1256
1257#[derive(Clone, Copy, Debug)]
1258enum OpenAIApiMode {
1259 Chat,
1260 Responses,
1261}
1262
1263#[async_trait]
1264impl Model for OpenAIUnifiedModel {
1265 fn name(&self) -> &str {
1266 &self.model
1267 }
1268
1269 async fn request(
1270 &self,
1271 messages: &[ModelMessage],
1272 settings: Option<&ModelSettings>,
1273 params: &ModelRequestParameters,
1274 ) -> Result<ModelResponse, ModelError> {
1275 match self.select_api(messages, false)? {
1276 OpenAIApiMode::Chat => self.chat.request(messages, settings, params).await,
1277 OpenAIApiMode::Responses => self.responses.request(messages, settings, params).await,
1278 }
1279 }
1280
1281 async fn request_stream(
1282 &self,
1283 messages: &[ModelMessage],
1284 settings: Option<&ModelSettings>,
1285 params: &ModelRequestParameters,
1286 ) -> Result<ModelStream, ModelError> {
1287 match self.select_api(messages, true)? {
1288 OpenAIApiMode::Chat => self.chat.request_stream(messages, settings, params).await,
1289 OpenAIApiMode::Responses => Err(ModelError::Unsupported(
1290 "streaming not supported for OpenAI Responses API".to_string(),
1291 )),
1292 }
1293 }
1294}
1295
1296#[derive(Clone, Debug)]
1297pub struct OpenAIResponsesModel {
1298 model: String,
1299 api_key: String,
1300 base_url: Url,
1301 client: Client,
1302 default_settings: Option<ModelSettings>,
1303}
1304
1305impl OpenAIResponsesModel {
1306 pub fn new(
1307 model: impl Into<String>,
1308 api_key: String,
1309 base_url: Url,
1310 settings: Option<ModelSettings>,
1311 ) -> Self {
1312 Self {
1313 model: model.into(),
1314 api_key,
1315 base_url,
1316 client: Client::new(),
1317 default_settings: settings,
1318 }
1319 }
1320
1321 fn endpoint(&self) -> Result<Url, ModelError> {
1322 join_path(&self.base_url, "responses")
1323 }
1324
1325 fn filename_for_media_type(media_type: &str) -> String {
1326 let ext = match media_type {
1327 "application/pdf" => "pdf",
1328 "text/plain" => "txt",
1329 "text/markdown" => "md",
1330 "application/json" => "json",
1331 _ => "bin",
1332 };
1333 format!("file.{ext}")
1334 }
1335
1336 fn make_input_messages(&self, messages: &[ModelMessage]) -> Result<Vec<Value>, ModelError> {
1337 let mut out = Vec::new();
1338 for message in messages {
1339 match message {
1340 ModelMessage::Request(req) => {
1341 if let Some(instructions) = req
1342 .instructions
1343 .as_ref()
1344 .filter(|value| !value.trim().is_empty())
1345 {
1346 out.push(json!({"role": "system", "content": instructions}));
1347 }
1348 for part in &req.parts {
1349 match part {
1350 ModelRequestPart::SystemPrompt(prompt) => {
1351 out.push(json!({"role": "system", "content": prompt.content}))
1352 }
1353 ModelRequestPart::UserPrompt(prompt) => {
1354 let content = self.convert_user_content(&prompt.content)?;
1355 out.push(json!({"role": "user", "content": content}))
1356 }
1357 ModelRequestPart::ToolReturn(tool_return) => {
1358 let content = tool_return_content(&tool_return.content);
1359 out.push(json!({
1360 "type": "function_call_output",
1361 "call_id": normalize_tool_call_id_str(&tool_return.tool_call_id),
1362 "output": content,
1363 }))
1364 }
1365 ModelRequestPart::RetryPrompt(retry) => {
1366 if retry.tool_name.is_some() {
1367 out.push(json!({
1368 "type": "function_call_output",
1369 "call_id": normalize_tool_call_id(retry.tool_call_id.clone()),
1370 "output": retry.content,
1371 }));
1372 } else {
1373 out.push(json!({
1374 "role": "user",
1375 "content": [ { "type": "input_text", "text": retry.content } ],
1376 }));
1377 }
1378 }
1379 }
1380 }
1381 }
1382 ModelMessage::Response(res) => {
1383 let provider_items: Vec<Value> = res
1384 .parts
1385 .iter()
1386 .filter_map(|part| match part {
1387 ModelResponsePart::ProviderItem(item)
1388 if item.provider == "openai_responses" =>
1389 {
1390 Some(item.payload.clone())
1391 }
1392 _ => None,
1393 })
1394 .collect();
1395 if !provider_items.is_empty() {
1396 out.extend(provider_items);
1397 continue;
1398 }
1399 if let Some(text) = res.text() {
1400 out.push(json!({"role": "assistant", "content": text}));
1401 }
1402 for call in res.tool_calls() {
1403 let args = tool_call_arguments(&call.arguments);
1404 out.push(json!({
1405 "type": "function_call",
1406 "call_id": normalize_tool_call_id_str(&call.id),
1407 "name": call.name,
1408 "arguments": args,
1409 }));
1410 }
1411 }
1412 }
1413 }
1414 Ok(out)
1415 }
1416
1417 fn convert_user_content(&self, content: &[UserContent]) -> Result<Value, ModelError> {
1418 let mut parts = Vec::new();
1419 for item in content {
1420 match item {
1421 UserContent::Text(text) => parts.push(json!({"type": "input_text", "text": text})),
1422 UserContent::Image(image) => parts.push(json!({
1423 "type": "input_image",
1424 "image_url": image.url
1425 })),
1426 UserContent::Binary(BinaryContent { data, media_type }) => {
1427 if media_type.starts_with("image/") {
1428 let encoded = general_purpose::STANDARD.encode(data);
1429 let data_url = format!("data:{};base64,{}", media_type, encoded);
1430 parts.push(json!({
1431 "type": "input_image",
1432 "image_url": data_url
1433 }));
1434 } else if media_type == "application/pdf" {
1435 let encoded = general_purpose::STANDARD.encode(data);
1436 let data_url = format!("data:{};base64,{}", media_type, encoded);
1437 parts.push(json!({
1438 "type": "input_file",
1439 "file_data": data_url,
1440 "filename": Self::filename_for_media_type(media_type),
1441 }));
1442 } else if is_text_like_media_type(media_type) {
1443 match std::str::from_utf8(data) {
1444 Ok(text) => parts.push(json!({"type": "input_text", "text": text})),
1445 Err(_) => parts.push(json!({
1446 "type": "input_text",
1447 "text": format!("[binary content: {} bytes]", data.len())
1448 })),
1449 }
1450 } else {
1451 parts.push(json!({
1452 "type": "input_text",
1453 "text": format!("[binary content: {} bytes]", data.len())
1454 }))
1455 }
1456 }
1457 UserContent::Document(doc) => {
1458 if let Some((media_type, data)) = parse_data_url_base64(&doc.url) {
1459 let data_url = format!("data:{};base64,{}", media_type, data);
1460 parts.push(json!({
1461 "type": "input_file",
1462 "file_data": data_url,
1463 "filename": Self::filename_for_media_type(&media_type),
1464 }));
1465 } else {
1466 parts.push(json!({
1467 "type": "input_file",
1468 "file_url": doc.url
1469 }));
1470 }
1471 }
1472 UserContent::Audio(audio) => parts.push(json!({
1473 "type": "input_text",
1474 "text": format!("[audio: {}]", audio.url)
1475 })),
1476 UserContent::Video(video) => parts.push(json!({
1477 "type": "input_text",
1478 "text": format!("[video: {}]", video.url)
1479 })),
1480 }
1481 }
1482 Ok(Value::Array(parts))
1483 }
1484
1485 fn build_body(
1486 &self,
1487 messages: &[ModelMessage],
1488 params: &ModelRequestParameters,
1489 ) -> Result<Value, ModelError> {
1490 let mut body = Map::new();
1491 body.insert("model".to_string(), Value::String(self.model.clone()));
1492 body.insert(
1493 "input".to_string(),
1494 Value::Array(self.make_input_messages(messages)?),
1495 );
1496
1497 if !params.function_tools.is_empty() {
1498 let tools = params
1499 .function_tools
1500 .iter()
1501 .map(|tool| {
1502 let (schema, _strict_ok) =
1503 transform_openai_schema(&tool.parameters_json_schema, None);
1504 json!({
1505 "type": "function",
1506 "name": tool.name,
1507 "description": tool.description,
1508 "parameters": schema,
1509 })
1510 })
1511 .collect();
1512 body.insert("tools".to_string(), Value::Array(tools));
1513 if params.function_tools.iter().any(|tool| tool.sequential) {
1514 body.insert("parallel_tool_calls".to_string(), Value::Bool(false));
1515 }
1516 }
1517
1518 if params.output_mode == OutputMode::JsonSchema
1519 && let Some(schema) = params.output_schema.clone()
1520 {
1521 let strict = !params.allow_text_output;
1522 let (schema, _strict_ok) = transform_openai_schema(&schema, Some(strict));
1523 body.insert(
1524 "text".to_string(),
1525 json!({
1526 "format": {
1527 "type": "json_schema",
1528 "name": "output",
1529 "schema": schema,
1530 "strict": strict,
1531 }
1532 }),
1533 );
1534 }
1535
1536 if let Some(settings) = &self.default_settings {
1537 for (key, value) in settings {
1538 if key == "max_tokens" && !body.contains_key("max_output_tokens") {
1539 body.insert("max_output_tokens".to_string(), value.clone());
1540 } else {
1541 body.insert(key.clone(), value.clone());
1542 }
1543 }
1544 }
1545
1546 Ok(Value::Object(body))
1547 }
1548}
1549
1550#[async_trait]
1551impl Model for OpenAIResponsesModel {
1552 fn name(&self) -> &str {
1553 &self.model
1554 }
1555
1556 async fn request(
1557 &self,
1558 messages: &[ModelMessage],
1559 settings: Option<&ModelSettings>,
1560 params: &ModelRequestParameters,
1561 ) -> Result<ModelResponse, ModelError> {
1562 tracing::debug!(
1563 model = %self.model,
1564 tool_count = params.function_tools.len(),
1565 output_schema = params.output_schema.is_some(),
1566 "OpenAI responses request"
1567 );
1568 let mut body = self.build_body(messages, params)?;
1569 if let Some(settings) = settings
1570 && let Value::Object(map) = &mut body
1571 {
1572 for (key, value) in settings {
1573 if key == "max_tokens" && !map.contains_key("max_output_tokens") {
1574 map.insert("max_output_tokens".to_string(), value.clone());
1575 } else {
1576 map.insert(key.clone(), value.clone());
1577 }
1578 }
1579 }
1580
1581 let response = self
1582 .client
1583 .post(self.endpoint()?)
1584 .bearer_auth(&self.api_key)
1585 .json(&body)
1586 .send()
1587 .await
1588 .map_err(|e| map_reqwest_error("OpenAI Responses", e))?;
1589
1590 let status = response.status();
1591 if !status.is_success() {
1592 let body = response.text().await.unwrap_or_default();
1593 tracing::error!(
1594 status = status.as_u16(),
1595 model = %self.model,
1596 body = %truncate_error_body(&body),
1597 "OpenAI responses request failed"
1598 );
1599 return Err(ModelError::HttpStatus {
1600 status: status.as_u16(),
1601 });
1602 }
1603
1604 let body: OpenAIResponsesResponse = response.json().await.map_err(|e| {
1605 tracing::error!(
1606 error = %e,
1607 model = %self.model,
1608 "OpenAI responses parse failed"
1609 );
1610 ModelError::Provider(format!("OpenAI response parse failed: {e}"))
1611 })?;
1612
1613 let mut parts = Vec::new();
1614 for item in body.output {
1615 parts.push(ModelResponsePart::ProviderItem(ProviderItemPart {
1616 provider: "openai_responses".to_string(),
1617 payload: item.clone(),
1618 }));
1619
1620 if let Some(item_type) = item.get("type").and_then(|value| value.as_str()) {
1621 match item_type {
1622 "message" => {
1623 if let Some(content) =
1624 item.get("content").and_then(|value| value.as_array())
1625 {
1626 for part in content {
1627 if part.get("type").and_then(|value| value.as_str())
1628 == Some("output_text")
1629 && let Some(text) =
1630 part.get("text").and_then(|value| value.as_str())
1631 {
1632 parts.push(ModelResponsePart::Text(TextPart {
1633 content: text.to_string(),
1634 }));
1635 }
1636 }
1637 }
1638 }
1639 "function_call" => {
1640 let name = item
1641 .get("name")
1642 .and_then(|value| value.as_str())
1643 .unwrap_or("tool")
1644 .to_string();
1645 let call_id = item
1646 .get("call_id")
1647 .and_then(|value| value.as_str())
1648 .map(str::to_string);
1649 let arguments = item.get("arguments").cloned().unwrap_or(Value::Null);
1650 let args = match arguments {
1651 Value::String(value) => serde_json::from_str::<Value>(&value)
1652 .unwrap_or(Value::String(value)),
1653 other => other,
1654 };
1655 parts.push(ModelResponsePart::ToolCall(ToolCallPart {
1656 id: normalize_tool_call_id(call_id),
1657 name,
1658 arguments: args,
1659 }));
1660 }
1661 _ => {}
1662 }
1663 }
1664 }
1665
1666 let usage = body.usage.map(|usage| RequestUsage {
1667 input_tokens: usage.input_tokens.unwrap_or(0),
1668 output_tokens: usage.output_tokens.unwrap_or(0),
1669 ..Default::default()
1670 });
1671
1672 Ok(ModelResponse {
1673 parts,
1674 usage,
1675 model_name: body.model.or_else(|| Some(self.model.clone())),
1676 finish_reason: body.finish_reason,
1677 })
1678 }
1679}
1680
1681#[derive(Debug, Deserialize)]
1682struct OpenAIResponsesResponse {
1683 output: Vec<Value>,
1684 usage: Option<OpenAIResponsesUsage>,
1685 model: Option<String>,
1686 #[serde(rename = "finish_reason")]
1687 finish_reason: Option<String>,
1688}
1689
1690#[derive(Debug, Deserialize)]
1691struct OpenAIResponsesUsage {
1692 input_tokens: Option<u64>,
1693 output_tokens: Option<u64>,
1694}