Skip to main content

rustic_ai/providers/
openai.rs

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}