Skip to main content

agent_sdk_providers/impls/
openai_responses.rs

1//! `OpenAI` Responses API provider implementation.
2//!
3//! This module provides an implementation of `LlmProvider` for the `OpenAI`
4//! Responses API (`/v1/responses`). This provider supports the Codex model family
5//! and other agentic `OpenAI` models that expose the Responses surface.
6
7use crate::attachments::validate_request_attachments;
8use crate::provider::LlmProvider;
9use crate::streaming::{SseLineBuffer, StreamBox, StreamDelta, StreamErrorKind};
10use agent_sdk_foundation::llm::{
11    ChatOutcome, ChatRequest, ChatResponse, Content, ContentBlock, Effort, ResponseFormat,
12    StopReason, ThinkingConfig, ThinkingMode, ToolChoice, Usage,
13};
14use anyhow::Result;
15use async_trait::async_trait;
16use futures::StreamExt;
17use reqwest::StatusCode;
18use serde::{Deserialize, Serialize};
19
20const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
21
22/// Build an HTTP client with connect/keepalive timeouts matching the sibling
23/// providers (`anthropic`, `vertex`). A bare `reqwest::Client::new()` has no
24/// connect timeout, so a black-holed connect would wedge `chat`/`chat_stream`
25/// forever.
26fn build_http_client() -> reqwest::Client {
27    reqwest::Client::builder()
28        .connect_timeout(std::time::Duration::from_secs(30))
29        .tcp_keepalive(std::time::Duration::from_secs(30))
30        .build()
31        .unwrap_or_default()
32}
33
34// GPT-5.3-Codex (latest Codex model)
35pub const MODEL_GPT53_CODEX: &str = "gpt-5.3-codex";
36
37// GPT-5.2-Codex (legacy Responses-first codex model)
38pub const MODEL_GPT52_CODEX: &str = "gpt-5.2-codex";
39
40/// Reasoning effort level for the model.
41#[derive(Clone, Copy, Debug, Default, Serialize)]
42#[serde(rename_all = "lowercase")]
43pub enum ReasoningEffort {
44    Low,
45    #[default]
46    Medium,
47    High,
48    /// Extra-high reasoning for complex problems
49    #[serde(rename = "xhigh")]
50    XHigh,
51}
52
53/// `OpenAI` Responses API provider.
54///
55/// This provider uses the `/v1/responses` endpoint for `OpenAI` models that expose
56/// agentic workflows over the Responses API.
57#[derive(Clone)]
58pub struct OpenAIResponsesProvider {
59    client: reqwest::Client,
60    api_key: String,
61    model: String,
62    base_url: String,
63    thinking: Option<ThinkingConfig>,
64    /// Extra headers applied to every request (e.g. for gateway / BYOK auth).
65    extra_headers: Vec<(String, String)>,
66}
67
68impl OpenAIResponsesProvider {
69    /// Create a new `OpenAI` Responses API provider.
70    #[must_use]
71    pub fn new(api_key: String, model: String) -> Self {
72        Self {
73            client: build_http_client(),
74            api_key,
75            model,
76            base_url: DEFAULT_BASE_URL.to_owned(),
77            thinking: None,
78            extra_headers: Vec::new(),
79        }
80    }
81
82    /// Create a provider with a custom base URL.
83    #[must_use]
84    pub fn with_base_url(api_key: String, model: String, base_url: String) -> Self {
85        Self {
86            client: build_http_client(),
87            api_key,
88            model,
89            base_url,
90            thinking: None,
91            extra_headers: Vec::new(),
92        }
93    }
94
95    /// Add extra HTTP headers applied to every request.
96    ///
97    /// Used by [`OpenAIProvider`](super::openai::OpenAIProvider)'s transparent
98    /// Responses-API reroute to forward its BYOK / gateway auth headers (e.g.
99    /// `cf-aig-authorization`) so a rerouted request authenticates correctly.
100    #[must_use]
101    pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
102        self.extra_headers = headers;
103        self
104    }
105
106    /// Reuse an existing pooled `reqwest::Client` instead of building a fresh one.
107    ///
108    /// `reqwest::Client` is an `Arc` handle (cheap to clone) backed by a
109    /// connection pool; reusing it across the reroute preserves keep-alive so a
110    /// rerouted agent loop does not pay a new TCP+TLS handshake every turn.
111    #[must_use]
112    pub(crate) fn with_client(mut self, client: reqwest::Client) -> Self {
113        self.client = client;
114        self
115    }
116
117    /// Apply auth + extra headers to a request builder. Skips `Authorization`
118    /// when `api_key` is empty (BYOK gateway mode — auth is carried by
119    /// `extra_headers`).
120    fn apply_headers(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
121        let builder = if self.api_key.is_empty() {
122            builder
123        } else {
124            builder.header("Authorization", format!("Bearer {}", self.api_key))
125        };
126        self.extra_headers
127            .iter()
128            .fold(builder, |b, (k, v)| b.header(k.as_str(), v.as_str()))
129    }
130
131    /// Create a provider using GPT-5.3-Codex (latest codex model).
132    #[must_use]
133    pub fn gpt53_codex(api_key: String) -> Self {
134        Self::new(api_key, MODEL_GPT53_CODEX.to_owned())
135    }
136
137    /// Create a provider using the latest Codex model.
138    #[must_use]
139    pub fn codex(api_key: String) -> Self {
140        Self::gpt53_codex(api_key)
141    }
142
143    /// Set the provider-owned thinking configuration for this model.
144    #[must_use]
145    pub const fn with_thinking(mut self, thinking: ThinkingConfig) -> Self {
146        self.thinking = Some(thinking);
147        self
148    }
149
150    /// Set the reasoning effort level.
151    #[must_use]
152    pub fn with_reasoning_effort(self, effort: ReasoningEffort) -> Self {
153        self.with_thinking(ThinkingConfig::default().with_effort(map_reasoning_effort(effort)))
154    }
155}
156
157#[async_trait]
158impl LlmProvider for OpenAIResponsesProvider {
159    async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
160        let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
161            Ok(thinking) => thinking,
162            Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
163        };
164        if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
165            return Ok(ChatOutcome::InvalidRequest(error.to_string()));
166        }
167        let reasoning = build_api_reasoning(thinking_config.as_ref());
168        let input = build_api_input(&request);
169        let text = request.response_format.as_ref().map(ApiResponseText::from);
170        let tool_choice = request.tool_choice.as_ref().map(ApiToolChoice::from);
171        let tools: Option<Vec<ApiTool>> = request
172            .tools
173            .map(|ts| ts.into_iter().map(convert_tool).collect());
174        let parallel_tool_calls = tools.as_ref().is_some_and(|tools| !tools.is_empty());
175
176        let api_request = ApiResponsesRequest {
177            model: &self.model,
178            input: &input,
179            tools: tools.as_deref(),
180            max_output_tokens: Some(request.max_tokens),
181            reasoning,
182            parallel_tool_calls: parallel_tool_calls.then_some(true),
183            text,
184            tool_choice,
185        };
186
187        log::debug!(
188            "OpenAI Responses API request model={} max_tokens={}",
189            self.model,
190            request.max_tokens
191        );
192
193        let builder = self
194            .client
195            .post(format!("{}/responses", self.base_url))
196            .header("Content-Type", "application/json");
197        let response = self
198            .apply_headers(builder)
199            .json(&api_request)
200            .send()
201            .await
202            .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
203
204        let status = response.status();
205        // Read `Retry-After` off the 429 response before the body is consumed.
206        let retry_after = if status == StatusCode::TOO_MANY_REQUESTS {
207            crate::http::retry_after_from_headers(response.headers())
208        } else {
209            None
210        };
211        let bytes = response
212            .bytes()
213            .await
214            .map_err(|e| anyhow::anyhow!("failed to read response body: {e}"))?;
215
216        log::debug!(
217            "OpenAI Responses API response status={} body_len={}",
218            status,
219            bytes.len()
220        );
221
222        if let Some(outcome) = classify_responses_status(status, &bytes, retry_after) {
223            return Ok(outcome);
224        }
225
226        let api_response: ApiResponse = serde_json::from_slice(&bytes)
227            .map_err(|e| anyhow::anyhow!("failed to parse response: {e}"))?;
228
229        Ok(build_responses_outcome(api_response))
230    }
231
232    #[allow(clippy::too_many_lines)]
233    fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
234        Box::pin(async_stream::stream! {
235            let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
236                Ok(thinking) => thinking,
237                Err(error) => {
238                    yield Ok(StreamDelta::Error {
239                        message: error.to_string(),
240                        kind: StreamErrorKind::InvalidRequest,
241                    });
242                    return;
243                }
244            };
245            if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
246                yield Ok(StreamDelta::Error {
247                    message: error.to_string(),
248                    kind: StreamErrorKind::InvalidRequest,
249                });
250                return;
251            }
252            let reasoning = build_api_reasoning(thinking_config.as_ref());
253            let input = build_api_input(&request);
254            let text = request.response_format.as_ref().map(ApiResponseText::from);
255            let tool_choice = request.tool_choice.as_ref().map(ApiToolChoice::from);
256            let tools: Option<Vec<ApiTool>> = request
257                .tools
258                .map(|ts| ts.into_iter().map(convert_tool).collect());
259            let parallel_tool_calls = tools.as_ref().is_some_and(|tools| !tools.is_empty());
260
261            let api_request = ApiResponsesRequestStreaming {
262                model: &self.model,
263                input: &input,
264                tools: tools.as_deref(),
265                max_output_tokens: Some(request.max_tokens),
266                reasoning,
267                parallel_tool_calls: parallel_tool_calls.then_some(true),
268                text,
269                tool_choice,
270                stream: true,
271            };
272
273            log::debug!("OpenAI Responses API streaming request model={} max_tokens={}", self.model, request.max_tokens);
274
275            let stream_builder = self.client
276                .post(format!("{}/responses", self.base_url))
277                .header("Content-Type", "application/json");
278            let Ok(response) = self
279                .apply_headers(stream_builder)
280                .json(&api_request)
281                .send()
282                .await
283            else {
284                yield Err(anyhow::anyhow!("request failed"));
285                return;
286            };
287
288            let status = response.status();
289
290            if !status.is_success() {
291                let body = response.text().await.unwrap_or_default();
292                let kind = if status == StatusCode::TOO_MANY_REQUESTS {
293                    StreamErrorKind::RateLimited
294                } else if status.is_server_error() {
295                    StreamErrorKind::ServerError
296                } else {
297                    StreamErrorKind::InvalidRequest
298                };
299                log::warn!("OpenAI Responses error status={status} body={body}");
300                yield Ok(StreamDelta::Error { message: body, kind });
301                return;
302            }
303
304            let mut sse = SseLineBuffer::new();
305            let mut stream = response.bytes_stream();
306            let mut usage: Option<Usage> = None;
307            let mut tool_calls: std::collections::HashMap<String, ToolCallAccumulator> =
308                std::collections::HashMap::new();
309
310            while let Some(chunk_result) = stream.next().await {
311                let Ok(chunk) = chunk_result else {
312                    yield Err(anyhow::anyhow!("stream error"));
313                    return;
314                };
315                sse.extend(&chunk);
316
317                while let Some(line) = sse.next_line() {
318                    let line = line.trim();
319                    if line.is_empty() { continue; }
320
321                    let Some(data) = line.strip_prefix("data: ") else {
322                        log::trace!("Responses SSE non-data line: {line}");
323                        continue;
324                    };
325                    if log::log_enabled!(log::Level::Trace) {
326                        let truncated: String = data.chars().take(200).collect();
327                        log::trace!("Responses SSE data: {truncated}");
328                    }
329
330                    if data == "[DONE]" {
331                        // Emit any accumulated tool calls with distinct,
332                        // registration-ordered block indices.
333                        for delta in flush_responses_tool_calls(&tool_calls) {
334                            yield Ok(delta);
335                        }
336
337                        if let Some(u) = usage.take() {
338                            yield Ok(StreamDelta::Usage(u));
339                        }
340
341                        let stop_reason = if tool_calls.is_empty() {
342                            StopReason::EndTurn
343                        } else {
344                            StopReason::ToolUse
345                        };
346                        yield Ok(StreamDelta::Done { stop_reason: Some(stop_reason) });
347                        return;
348                    }
349
350                    // Parse streaming event
351                    let parse_result = serde_json::from_str::<ApiStreamEvent>(data);
352                    if parse_result.is_err() {
353                        log::debug!("Failed to parse Responses SSE event: {data}");
354                    }
355                    if let Ok(event) = parse_result {
356                        match event.r#type.as_str() {
357                            // ── Content deltas ──────────────────────────
358                            "response.output_text.delta" => {
359                                if let Some(delta) = event.delta {
360                                    yield Ok(StreamDelta::TextDelta { delta, block_index: 0 });
361                                }
362                            }
363                            "response.output_item.added" => {
364                                // Register function_call items so we know
365                                // the call_id and name before deltas arrive.
366                                if let Some(item) = &event.item
367                                    && item.r#type.as_deref() == Some("function_call")
368                                    && let (Some(item_id), Some(call_id), Some(name)) =
369                                        (&item.id, &item.call_id, &item.name)
370                                {
371                                    let order = tool_calls.len();
372                                    tool_calls
373                                        .entry(item_id.clone())
374                                        .or_insert_with(|| ToolCallAccumulator {
375                                            id: call_id.clone(),
376                                            name: name.clone(),
377                                            arguments: String::new(),
378                                            order,
379                                        });
380                                }
381                            }
382                            "response.function_call_arguments.delta" => {
383                                if let (Some(item_id), Some(delta)) =
384                                    (event.resolve_item_id().map(str::to_owned), event.delta)
385                                {
386                                    let order = tool_calls.len();
387                                    let acc =
388                                        tool_calls.entry(item_id.clone()).or_insert_with(|| {
389                                            ToolCallAccumulator {
390                                                id: item_id,
391                                                name: event.name.unwrap_or_default(),
392                                                arguments: String::new(),
393                                                order,
394                                            }
395                                        });
396                                    acc.arguments.push_str(&delta);
397                                }
398                            }
399                            // ── Reasoning (thinking) deltas ─────────────
400                            "response.reasoning.delta" => {
401                                if let Some(delta) = event.delta {
402                                    yield Ok(StreamDelta::ThinkingDelta {
403                                        delta,
404                                        block_index: 0,
405                                    });
406                                }
407                            }
408                            // ── Completion / usage ──────────────────────
409                            "response.completed" => {
410                                if let Some(resp) = event.response
411                                    && let Some(u) = resp.usage
412                                {
413                                    usage = Some(Usage {
414                                        input_tokens: u.input_tokens,
415                                        output_tokens: u.output_tokens,
416                                        cached_input_tokens: u
417                                            .input_tokens_details
418                                            .as_ref()
419                                            .map_or(0, |details| details.cached_tokens),
420                                        cache_creation_input_tokens: 0,
421                                    });
422                                }
423                            }
424                            // ── Error ───────────────────────────────────
425                            "error" | "response.failed" => {
426                                let is_server_error = data.contains("server_error");
427                                let kind = if is_server_error {
428                                    log::warn!("Responses API server error (recoverable): {data}");
429                                    StreamErrorKind::ServerError
430                                } else {
431                                    log::error!("Responses API error event: {data}");
432                                    StreamErrorKind::InvalidRequest
433                                };
434                                yield Ok(StreamDelta::Error {
435                                    message: data.to_owned(),
436                                    kind,
437                                });
438                                return;
439                            }
440                            // ── Lifecycle events (no content) ───────────
441                            "response.created"
442                            | "response.in_progress"
443                            | "response.output_item.done"
444                            | "response.content_part.added"
445                            | "response.content_part.done"
446                            | "response.output_text.done"
447                            | "response.function_call_arguments.done"
448                            | "response.reasoning.done"
449                            | "response.reasoning_summary_text.delta"
450                            | "response.reasoning_summary_text.done" => {}
451                            // ── Unknown ─────────────────────────────────
452                            other => {
453                                log::debug!("Unhandled Responses SSE event type: {other}");
454                            }
455                        }
456                    }
457                }
458            }
459
460            // Stream ended without [DONE] — flush accumulated tool calls with
461            // distinct, registration-ordered block indices.
462            for delta in flush_responses_tool_calls(&tool_calls) {
463                yield Ok(delta);
464            }
465
466            if let Some(u) = usage {
467                yield Ok(StreamDelta::Usage(u));
468            }
469
470            let stop_reason = if tool_calls.is_empty() {
471                StopReason::EndTurn
472            } else {
473                StopReason::ToolUse
474            };
475            yield Ok(StreamDelta::Done { stop_reason: Some(stop_reason) });
476        })
477    }
478
479    fn model(&self) -> &str {
480        &self.model
481    }
482
483    fn provider(&self) -> &'static str {
484        "openai-responses"
485    }
486
487    fn configured_thinking(&self) -> Option<&ThinkingConfig> {
488        self.thinking.as_ref()
489    }
490}
491
492// ============================================================================
493// Input building
494// ============================================================================
495
496fn build_api_input(request: &ChatRequest) -> Vec<ApiInputItem> {
497    let mut items = Vec::new();
498
499    // Add system message if present
500    if !request.system.is_empty() {
501        items.push(ApiInputItem::Message(ApiMessage {
502            role: ApiRole::System,
503            content: ApiMessageContent::Text(request.system.clone()),
504        }));
505    }
506
507    // Convert messages
508    for msg in &request.messages {
509        match &msg.content {
510            Content::Text(text) => {
511                items.push(ApiInputItem::Message(ApiMessage {
512                    role: match msg.role {
513                        agent_sdk_foundation::llm::Role::User => ApiRole::User,
514                        agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
515                    },
516                    content: ApiMessageContent::Text(text.clone()),
517                }));
518            }
519            Content::Blocks(blocks) => {
520                let mut content_parts = Vec::new();
521
522                for block in blocks {
523                    match block {
524                        ContentBlock::Text { text } => {
525                            let part = match msg.role {
526                                agent_sdk_foundation::llm::Role::Assistant => {
527                                    ApiInputContent::OutputText { text: text.clone() }
528                                }
529                                agent_sdk_foundation::llm::Role::User => {
530                                    ApiInputContent::InputText { text: text.clone() }
531                                }
532                            };
533                            content_parts.push(part);
534                        }
535                        ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => {}
536                        ContentBlock::Image { source } => {
537                            content_parts.push(ApiInputContent::Image {
538                                image_url: format!(
539                                    "data:{};base64,{}",
540                                    source.media_type, source.data
541                                ),
542                            });
543                        }
544                        ContentBlock::Document { source } => {
545                            content_parts.push(ApiInputContent::File {
546                                filename: suggested_filename(&source.media_type),
547                                file_data: format!(
548                                    "data:{};base64,{}",
549                                    source.media_type, source.data
550                                ),
551                            });
552                        }
553                        ContentBlock::ToolUse {
554                            id, name, input, ..
555                        } => {
556                            items.push(ApiInputItem::FunctionCall(ApiFunctionCall::new(
557                                id.clone(),
558                                name.clone(),
559                                serde_json::to_string(input).unwrap_or_default(),
560                            )));
561                        }
562                        ContentBlock::ToolResult {
563                            tool_use_id,
564                            content,
565                            ..
566                        } => {
567                            items.push(ApiInputItem::FunctionCallOutput(
568                                ApiFunctionCallOutput::new(tool_use_id.clone(), content.clone()),
569                            ));
570                        }
571                        // `ContentBlock` is `#[non_exhaustive]`; a block kind this
572                        // SDK version cannot represent on the wire is skipped.
573                        _ => {
574                            log::warn!("Skipping unrecognized OpenAI Responses content block");
575                        }
576                    }
577                }
578
579                if !content_parts.is_empty() {
580                    items.push(ApiInputItem::Message(ApiMessage {
581                        role: match msg.role {
582                            agent_sdk_foundation::llm::Role::User => ApiRole::User,
583                            agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
584                        },
585                        content: ApiMessageContent::Parts(content_parts),
586                    }));
587                }
588            }
589        }
590    }
591
592    items
593}
594
595/// Recursively fix a JSON schema for `OpenAI` strict mode.
596/// Adds `additionalProperties: false` and ensures all properties are required.
597fn fix_schema_for_strict_mode(schema: &mut serde_json::Value) {
598    let Some(obj) = schema.as_object_mut() else {
599        return;
600    };
601
602    // Check if this is an object type schema
603    let is_object_type = obj
604        .get("type")
605        .is_some_and(|t| t.as_str() == Some("object"));
606
607    if is_object_type {
608        // Add additionalProperties: false
609        obj.insert(
610            "additionalProperties".to_owned(),
611            serde_json::Value::Bool(false),
612        );
613
614        // Ensure properties and required exist (strict mode needs them even if empty)
615        obj.entry("properties".to_owned())
616            .or_insert_with(|| serde_json::json!({}));
617        obj.entry("required".to_owned())
618            .or_insert_with(|| serde_json::json!([]));
619
620        // Collect the set of originally required keys
621        let originally_required: std::collections::HashSet<String> = obj
622            .get("required")
623            .and_then(|v| v.as_array())
624            .map(|arr| {
625                arr.iter()
626                    .filter_map(|v| v.as_str().map(String::from))
627                    .collect()
628            })
629            .unwrap_or_default();
630
631        // Wrap previously-optional properties in anyOf with null
632        if let Some(serde_json::Value::Object(props)) = obj.get_mut("properties") {
633            for (key, prop_schema) in props.iter_mut() {
634                if !originally_required.contains(key) {
635                    make_nullable(prop_schema);
636                }
637            }
638        }
639
640        // Ensure all properties are marked as required
641        if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
642            let all_keys: Vec<serde_json::Value> = props
643                .keys()
644                .map(|k| serde_json::Value::String(k.clone()))
645                .collect();
646            obj.insert("required".to_owned(), serde_json::Value::Array(all_keys));
647        }
648    }
649
650    // Recursively process nested schemas
651    if let Some(props) = obj.get_mut("properties")
652        && let Some(props_obj) = props.as_object_mut()
653    {
654        for prop_schema in props_obj.values_mut() {
655            fix_schema_for_strict_mode(prop_schema);
656        }
657    }
658
659    // Process array items
660    if let Some(items) = obj.get_mut("items") {
661        fix_schema_for_strict_mode(items);
662    }
663
664    // Process anyOf/oneOf/allOf
665    for key in ["anyOf", "oneOf", "allOf"] {
666        if let Some(arr) = obj.get_mut(key)
667            && let Some(arr_items) = arr.as_array_mut()
668        {
669            for item in arr_items {
670                fix_schema_for_strict_mode(item);
671            }
672        }
673    }
674}
675
676fn convert_tool(tool: agent_sdk_foundation::llm::Tool) -> ApiTool {
677    let mut schema = tool.input_schema;
678
679    // Strict mode requires additionalProperties: false on all objects and
680    // every property in required. This is incompatible with free-form object
681    // schemas (objects with no defined properties). Detect and skip strict
682    // for those tools.
683    let use_strict = if has_freeform_object(&schema) {
684        log::debug!(
685            "Tool '{}' has free-form object schema — disabling strict mode",
686            tool.name
687        );
688        None
689    } else {
690        fix_schema_for_strict_mode(&mut schema);
691        Some(true)
692    };
693
694    ApiTool {
695        r#type: "function".to_owned(),
696        name: tool.name,
697        description: Some(tool.description),
698        parameters: Some(schema),
699        strict: use_strict,
700    }
701}
702
703/// Check if a JSON schema contains any object-typed properties without
704/// defined `properties` (free-form objects). These are incompatible with
705/// `OpenAI` strict mode.
706/// Wrap a schema in `anyOf: [{original}, {"type": "null"}]` so that
707/// the property accepts its original type OR null.
708///
709/// If the schema already has an `anyOf`, appends `{"type": "null"}` to it.
710fn make_nullable(schema: &mut serde_json::Value) {
711    // Already nullable via anyOf — append null variant if missing
712    if let Some(any_of) = schema
713        .as_object_mut()
714        .and_then(|o| o.get_mut("anyOf"))
715        .and_then(|v| v.as_array_mut())
716    {
717        let has_null = any_of
718            .iter()
719            .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"));
720        if !has_null {
721            any_of.push(serde_json::json!({"type": "null"}));
722        }
723        return;
724    }
725
726    // Wrap the original schema in anyOf
727    let original = schema.clone();
728    *schema = serde_json::json!({
729        "anyOf": [original, {"type": "null"}]
730    });
731}
732
733fn has_freeform_object(schema: &serde_json::Value) -> bool {
734    let Some(obj) = schema.as_object() else {
735        return false;
736    };
737
738    let is_object = obj
739        .get("type")
740        .is_some_and(|t| t.as_str() == Some("object"));
741
742    if is_object && !obj.contains_key("properties") {
743        return true;
744    }
745
746    // Recurse into properties
747    if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
748        for prop in props.values() {
749            if has_freeform_object(prop) {
750                return true;
751            }
752        }
753    }
754
755    // Recurse into array items
756    if let Some(items) = obj.get("items")
757        && has_freeform_object(items)
758    {
759        return true;
760    }
761
762    // Recurse into anyOf/oneOf/allOf
763    for key in ["anyOf", "oneOf", "allOf"] {
764        if let Some(arr) = obj.get(key).and_then(|v| v.as_array()) {
765            for item in arr {
766                if has_freeform_object(item) {
767                    return true;
768                }
769            }
770        }
771    }
772
773    false
774}
775
776fn suggested_filename(media_type: &str) -> String {
777    match media_type {
778        "application/pdf" => "attachment.pdf".to_string(),
779        "image/png" => "image.png".to_string(),
780        "image/jpeg" => "image.jpg".to_string(),
781        "image/gif" => "image.gif".to_string(),
782        "image/webp" => "image.webp".to_string(),
783        _ => "attachment.bin".to_string(),
784    }
785}
786
787fn build_content_blocks(output: &[ApiOutputItem]) -> Vec<ContentBlock> {
788    let mut blocks = Vec::new();
789
790    for item in output {
791        match item {
792            ApiOutputItem::Message { content, .. } => {
793                for c in content {
794                    if let ApiOutputContent::Text { text } = c
795                        && !text.is_empty()
796                    {
797                        blocks.push(ContentBlock::Text { text: text.clone() });
798                    }
799                }
800            }
801            ApiOutputItem::FunctionCall {
802                call_id,
803                name,
804                arguments,
805                ..
806            } => {
807                let input =
808                    serde_json::from_str(arguments).unwrap_or_else(|_| serde_json::json!({}));
809                blocks.push(ContentBlock::ToolUse {
810                    id: call_id.clone(),
811                    name: name.clone(),
812                    input,
813                    thought_signature: None,
814                });
815            }
816            ApiOutputItem::Unknown => {
817                // Skip unknown output types
818            }
819        }
820    }
821
822    blocks
823}
824
825/// Classify a non-success HTTP status into an early [`ChatOutcome`].
826///
827/// Returns `None` when the status is a success and the body should instead be
828/// parsed as an [`ApiResponse`].
829fn classify_responses_status(
830    status: StatusCode,
831    bytes: &[u8],
832    retry_after: Option<std::time::Duration>,
833) -> Option<ChatOutcome> {
834    if status == StatusCode::TOO_MANY_REQUESTS {
835        return Some(ChatOutcome::RateLimited(retry_after));
836    }
837    if status.is_server_error() {
838        let body = String::from_utf8_lossy(bytes);
839        log::error!("OpenAI Responses server error status={status} body={body}");
840        return Some(ChatOutcome::ServerError(body.into_owned()));
841    }
842    if status.is_client_error() {
843        let body = String::from_utf8_lossy(bytes);
844        log::warn!("OpenAI Responses client error status={status} body={body}");
845        return Some(ChatOutcome::InvalidRequest(body.into_owned()));
846    }
847    None
848}
849
850/// Map a parsed Responses API body into a [`ChatOutcome`].
851///
852/// The Responses API reports generation failures as HTTP 200 with
853/// `status=failed` plus an error object. That is surfaced as a server error
854/// instead of a successful turn with empty content (mirrors the streaming
855/// `response.failed` handling).
856fn build_responses_outcome(api_response: ApiResponse) -> ChatOutcome {
857    if matches!(api_response.status, Some(ApiStatus::Failed)) {
858        let message = api_response
859            .error
860            .and_then(|error| error.message)
861            .unwrap_or_else(|| "OpenAI Responses API reported status=failed".to_owned());
862        log::error!("OpenAI Responses generation failed: {message}");
863        return ChatOutcome::ServerError(message);
864    }
865
866    let content = build_content_blocks(&api_response.output);
867
868    // Determine stop reason based on output content
869    let has_tool_calls = content
870        .iter()
871        .any(|b| matches!(b, ContentBlock::ToolUse { .. }));
872
873    let stop_reason = if has_tool_calls {
874        Some(StopReason::ToolUse)
875    } else {
876        api_response.status.map(|s| match s {
877            ApiStatus::Completed => StopReason::EndTurn,
878            ApiStatus::Incomplete => StopReason::MaxTokens,
879            // Unreachable: Failed is handled above, but map defensively.
880            ApiStatus::Failed => StopReason::StopSequence,
881        })
882    };
883
884    ChatOutcome::Success(ChatResponse {
885        id: api_response.id,
886        content,
887        model: api_response.model,
888        stop_reason,
889        usage: map_usage(api_response.usage),
890    })
891}
892
893/// Convert the Responses API usage object into the SDK [`Usage`] shape.
894fn map_usage(usage: Option<ApiUsage>) -> Usage {
895    usage.map_or(
896        Usage {
897            input_tokens: 0,
898            output_tokens: 0,
899            cached_input_tokens: 0,
900            cache_creation_input_tokens: 0,
901        },
902        |u| Usage {
903            input_tokens: u.input_tokens,
904            output_tokens: u.output_tokens,
905            cached_input_tokens: u
906                .input_tokens_details
907                .as_ref()
908                .map_or(0, |details| details.cached_tokens),
909            cache_creation_input_tokens: 0,
910        },
911    )
912}
913
914fn build_api_reasoning(thinking: Option<&ThinkingConfig>) -> Option<ApiReasoning> {
915    thinking
916        .and_then(resolve_reasoning_effort)
917        .map(|effort| ApiReasoning { effort })
918}
919
920const fn resolve_reasoning_effort(config: &ThinkingConfig) -> Option<ReasoningEffort> {
921    if let Some(effort) = config.effort {
922        return Some(map_effort(effort));
923    }
924
925    match &config.mode {
926        ThinkingMode::Adaptive => None,
927        ThinkingMode::Enabled { budget_tokens } => Some(map_budget_to_reasoning(*budget_tokens)),
928    }
929}
930
931const fn map_effort(effort: Effort) -> ReasoningEffort {
932    match effort {
933        Effort::Low => ReasoningEffort::Low,
934        Effort::Medium => ReasoningEffort::Medium,
935        Effort::High => ReasoningEffort::High,
936        Effort::Max => ReasoningEffort::XHigh,
937    }
938}
939
940const fn map_reasoning_effort(effort: ReasoningEffort) -> Effort {
941    match effort {
942        ReasoningEffort::Low => Effort::Low,
943        ReasoningEffort::Medium => Effort::Medium,
944        ReasoningEffort::High => Effort::High,
945        ReasoningEffort::XHigh => Effort::Max,
946    }
947}
948
949const fn map_budget_to_reasoning(budget_tokens: u32) -> ReasoningEffort {
950    if budget_tokens <= 4_096 {
951        ReasoningEffort::Low
952    } else if budget_tokens <= 16_384 {
953        ReasoningEffort::Medium
954    } else if budget_tokens <= 32_768 {
955        ReasoningEffort::High
956    } else {
957        ReasoningEffort::XHigh
958    }
959}
960
961// ============================================================================
962// Streaming helpers
963// ============================================================================
964
965struct ToolCallAccumulator {
966    id: String,
967    name: String,
968    arguments: String,
969    /// Registration order, used to assign deterministic, distinct block indices
970    /// when flushing (`HashMap` iteration order is otherwise nondeterministic).
971    order: usize,
972}
973
974/// Emit accumulated tool calls as stream deltas with distinct, monotonically
975/// increasing block indices in registration order.
976///
977/// The previous implementation assigned every call `block_index: 1` and iterated
978/// `HashMap::values()`, so [`StreamAccumulator`](crate::streaming::StreamAccumulator)'s
979/// stable sort preserved nondeterministic insertion order — multi-tool turns
980/// replayed in different orders run to run. Sorting by registration order with a
981/// unique index per call makes the final content-block order deterministic.
982fn flush_responses_tool_calls(
983    tool_calls: &std::collections::HashMap<String, ToolCallAccumulator>,
984) -> Vec<StreamDelta> {
985    let mut accs: Vec<&ToolCallAccumulator> = tool_calls.values().collect();
986    accs.sort_by_key(|acc| acc.order);
987
988    let mut deltas = Vec::with_capacity(accs.len() * 2);
989    for (idx, acc) in accs.iter().enumerate() {
990        let block_index = idx + 1;
991        deltas.push(StreamDelta::ToolUseStart {
992            id: acc.id.clone(),
993            name: acc.name.clone(),
994            block_index,
995            thought_signature: None,
996        });
997        deltas.push(StreamDelta::ToolInputDelta {
998            id: acc.id.clone(),
999            delta: acc.arguments.clone(),
1000            block_index,
1001        });
1002    }
1003    deltas
1004}
1005
1006// ============================================================================
1007// API Request Types
1008// ============================================================================
1009
1010#[derive(Serialize)]
1011struct ApiResponsesRequest<'a> {
1012    model: &'a str,
1013    input: &'a [ApiInputItem],
1014    #[serde(skip_serializing_if = "Option::is_none")]
1015    tools: Option<&'a [ApiTool]>,
1016    #[serde(skip_serializing_if = "Option::is_none")]
1017    max_output_tokens: Option<u32>,
1018    #[serde(skip_serializing_if = "Option::is_none")]
1019    reasoning: Option<ApiReasoning>,
1020    #[serde(skip_serializing_if = "Option::is_none")]
1021    parallel_tool_calls: Option<bool>,
1022    #[serde(skip_serializing_if = "Option::is_none")]
1023    text: Option<ApiResponseText>,
1024    #[serde(skip_serializing_if = "Option::is_none")]
1025    tool_choice: Option<ApiToolChoice>,
1026}
1027
1028#[derive(Serialize)]
1029struct ApiResponsesRequestStreaming<'a> {
1030    model: &'a str,
1031    input: &'a [ApiInputItem],
1032    #[serde(skip_serializing_if = "Option::is_none")]
1033    tools: Option<&'a [ApiTool]>,
1034    #[serde(skip_serializing_if = "Option::is_none")]
1035    max_output_tokens: Option<u32>,
1036    #[serde(skip_serializing_if = "Option::is_none")]
1037    reasoning: Option<ApiReasoning>,
1038    #[serde(skip_serializing_if = "Option::is_none")]
1039    parallel_tool_calls: Option<bool>,
1040    #[serde(skip_serializing_if = "Option::is_none")]
1041    text: Option<ApiResponseText>,
1042    #[serde(skip_serializing_if = "Option::is_none")]
1043    tool_choice: Option<ApiToolChoice>,
1044    stream: bool,
1045}
1046
1047#[derive(Serialize)]
1048struct ApiReasoning {
1049    effort: ReasoningEffort,
1050}
1051
1052/// Responses API structured-output wire field: `{"text": {"format": {...}}}`.
1053///
1054/// The Responses API carries JSON-schema structured output under
1055/// `text.format` (type `json_schema`), unlike Chat Completions' top-level
1056/// `response_format`.
1057#[derive(Serialize)]
1058struct ApiResponseText {
1059    format: ApiResponseTextFormat,
1060}
1061
1062#[derive(Serialize)]
1063struct ApiResponseTextFormat {
1064    #[serde(rename = "type")]
1065    format_type: &'static str,
1066    name: String,
1067    schema: serde_json::Value,
1068    strict: bool,
1069}
1070
1071impl From<&ResponseFormat> for ApiResponseText {
1072    fn from(rf: &ResponseFormat) -> Self {
1073        Self {
1074            format: ApiResponseTextFormat {
1075                format_type: "json_schema",
1076                name: rf.name.clone(),
1077                schema: rf.schema.clone(),
1078                strict: rf.strict,
1079            },
1080        }
1081    }
1082}
1083
1084/// Responses API `tool_choice` wire format.
1085///
1086/// - `"auto"` — model decides.
1087/// - `{"type": "function", "name": "<name>"}` — force a specific function.
1088#[derive(Serialize)]
1089#[serde(untagged)]
1090enum ApiToolChoice {
1091    Mode(&'static str),
1092    Function {
1093        #[serde(rename = "type")]
1094        choice_type: &'static str,
1095        name: String,
1096    },
1097}
1098
1099impl From<&ToolChoice> for ApiToolChoice {
1100    fn from(tc: &ToolChoice) -> Self {
1101        match tc {
1102            ToolChoice::Auto => Self::Mode("auto"),
1103            ToolChoice::Tool(name) => Self::Function {
1104                choice_type: "function",
1105                name: name.clone(),
1106            },
1107        }
1108    }
1109}
1110
1111#[derive(Serialize)]
1112#[serde(untagged)]
1113enum ApiInputItem {
1114    Message(ApiMessage),
1115    FunctionCall(ApiFunctionCall),
1116    FunctionCallOutput(ApiFunctionCallOutput),
1117}
1118
1119#[derive(Serialize)]
1120struct ApiMessage {
1121    role: ApiRole,
1122    content: ApiMessageContent,
1123}
1124
1125#[derive(Serialize)]
1126#[serde(rename_all = "lowercase")]
1127enum ApiRole {
1128    System,
1129    User,
1130    Assistant,
1131}
1132
1133#[derive(Serialize)]
1134#[serde(untagged)]
1135enum ApiMessageContent {
1136    Text(String),
1137    Parts(Vec<ApiInputContent>),
1138}
1139
1140#[derive(Serialize)]
1141#[serde(tag = "type")]
1142enum ApiInputContent {
1143    #[serde(rename = "input_text")]
1144    InputText { text: String },
1145    #[serde(rename = "output_text")]
1146    OutputText { text: String },
1147    #[serde(rename = "input_image")]
1148    Image { image_url: String },
1149    #[serde(rename = "input_file")]
1150    File { filename: String, file_data: String },
1151}
1152
1153#[derive(Serialize)]
1154struct ApiFunctionCall {
1155    r#type: &'static str,
1156    call_id: String,
1157    name: String,
1158    arguments: String,
1159}
1160
1161impl ApiFunctionCall {
1162    const fn new(call_id: String, name: String, arguments: String) -> Self {
1163        Self {
1164            r#type: "function_call",
1165            call_id,
1166            name,
1167            arguments,
1168        }
1169    }
1170}
1171
1172#[derive(Serialize)]
1173struct ApiFunctionCallOutput {
1174    r#type: &'static str,
1175    call_id: String,
1176    output: String,
1177}
1178
1179impl ApiFunctionCallOutput {
1180    const fn new(call_id: String, output: String) -> Self {
1181        Self {
1182            r#type: "function_call_output",
1183            call_id,
1184            output,
1185        }
1186    }
1187}
1188
1189#[derive(Serialize)]
1190struct ApiTool {
1191    r#type: String,
1192    name: String,
1193    #[serde(skip_serializing_if = "Option::is_none")]
1194    description: Option<String>,
1195    #[serde(skip_serializing_if = "Option::is_none")]
1196    parameters: Option<serde_json::Value>,
1197    #[serde(skip_serializing_if = "Option::is_none")]
1198    strict: Option<bool>,
1199}
1200
1201// ============================================================================
1202// API Response Types
1203// ============================================================================
1204
1205#[derive(Deserialize)]
1206struct ApiResponse {
1207    id: String,
1208    model: String,
1209    output: Vec<ApiOutputItem>,
1210    #[serde(default)]
1211    status: Option<ApiStatus>,
1212    #[serde(default)]
1213    usage: Option<ApiUsage>,
1214    #[serde(default)]
1215    error: Option<ApiResponseError>,
1216}
1217
1218#[derive(Deserialize)]
1219struct ApiResponseError {
1220    #[serde(default)]
1221    message: Option<String>,
1222}
1223
1224#[derive(Deserialize)]
1225#[serde(rename_all = "snake_case")]
1226enum ApiStatus {
1227    Completed,
1228    Incomplete,
1229    Failed,
1230}
1231
1232#[derive(Deserialize)]
1233struct ApiUsage {
1234    input_tokens: u32,
1235    output_tokens: u32,
1236    #[serde(default)]
1237    input_tokens_details: Option<ApiInputTokensDetails>,
1238}
1239
1240#[derive(Deserialize)]
1241struct ApiInputTokensDetails {
1242    #[serde(default)]
1243    cached_tokens: u32,
1244}
1245
1246#[derive(Deserialize)]
1247#[serde(tag = "type")]
1248enum ApiOutputItem {
1249    #[serde(rename = "message")]
1250    Message {
1251        #[serde(rename = "role")]
1252        _role: String,
1253        content: Vec<ApiOutputContent>,
1254    },
1255    #[serde(rename = "function_call")]
1256    FunctionCall {
1257        call_id: String,
1258        name: String,
1259        arguments: String,
1260    },
1261    #[serde(other)]
1262    Unknown,
1263}
1264
1265#[derive(Deserialize)]
1266#[serde(tag = "type")]
1267enum ApiOutputContent {
1268    #[serde(rename = "output_text")]
1269    Text { text: String },
1270    #[serde(other)]
1271    Unknown,
1272}
1273
1274// ============================================================================
1275// Streaming Types
1276// ============================================================================
1277
1278#[derive(Deserialize)]
1279struct ApiStreamEvent {
1280    r#type: String,
1281    #[serde(default)]
1282    delta: Option<String>,
1283    /// Present on `output_item.added` / `output_item.done` for `function_call` items.
1284    #[serde(default)]
1285    item: Option<ApiStreamItem>,
1286    /// Present on `function_call_arguments.delta`.
1287    #[serde(default)]
1288    item_id: Option<String>,
1289    /// Legacy field — some older events use `call_id` instead of `item_id`.
1290    #[serde(default)]
1291    call_id: Option<String>,
1292    #[serde(default)]
1293    name: Option<String>,
1294    #[serde(default)]
1295    response: Option<ApiStreamResponse>,
1296}
1297
1298impl ApiStreamEvent {
1299    /// Resolve the item identifier from whichever field is present.
1300    fn resolve_item_id(&self) -> Option<&str> {
1301        self.item_id
1302            .as_deref()
1303            .or(self.call_id.as_deref())
1304            .or_else(|| self.item.as_ref().and_then(|i| i.id.as_deref()))
1305    }
1306}
1307
1308#[derive(Deserialize)]
1309struct ApiStreamItem {
1310    #[serde(default)]
1311    id: Option<String>,
1312    #[serde(default)]
1313    r#type: Option<String>,
1314    #[serde(default)]
1315    call_id: Option<String>,
1316    #[serde(default)]
1317    name: Option<String>,
1318}
1319
1320#[derive(Deserialize)]
1321struct ApiStreamResponse {
1322    #[serde(default)]
1323    usage: Option<ApiUsage>,
1324}
1325
1326// ============================================================================
1327// Tests
1328// ============================================================================
1329
1330#[cfg(test)]
1331mod tests {
1332    use super::*;
1333
1334    #[test]
1335    fn test_model_constant() {
1336        assert_eq!(MODEL_GPT53_CODEX, "gpt-5.3-codex");
1337        assert_eq!(MODEL_GPT52_CODEX, "gpt-5.2-codex");
1338    }
1339
1340    #[test]
1341    fn test_codex_factory() {
1342        let provider = OpenAIResponsesProvider::codex("test-key".to_string());
1343        assert_eq!(provider.model(), MODEL_GPT53_CODEX);
1344        assert_eq!(provider.provider(), "openai-responses");
1345    }
1346
1347    #[test]
1348    fn test_gpt53_codex_factory() {
1349        let provider = OpenAIResponsesProvider::gpt53_codex("test-key".to_string());
1350        assert_eq!(provider.model(), MODEL_GPT53_CODEX);
1351        assert_eq!(provider.provider(), "openai-responses");
1352    }
1353
1354    #[test]
1355    fn test_reasoning_effort_serialization() {
1356        let low = serde_json::to_string(&ReasoningEffort::Low).unwrap();
1357        assert_eq!(low, "\"low\"");
1358
1359        let xhigh = serde_json::to_string(&ReasoningEffort::XHigh).unwrap();
1360        assert_eq!(xhigh, "\"xhigh\"");
1361    }
1362
1363    #[test]
1364    fn test_with_reasoning_effort() {
1365        let provider = OpenAIResponsesProvider::codex("test-key".to_string())
1366            .with_reasoning_effort(ReasoningEffort::High);
1367        let thinking = provider.thinking.as_ref().unwrap();
1368        assert!(matches!(thinking.effort, Some(Effort::High)));
1369    }
1370
1371    #[test]
1372    fn test_build_api_reasoning_uses_explicit_effort() {
1373        let reasoning =
1374            build_api_reasoning(Some(&ThinkingConfig::adaptive_with_effort(Effort::Low))).unwrap();
1375        assert!(matches!(reasoning.effort, ReasoningEffort::Low));
1376    }
1377
1378    #[test]
1379    fn test_build_api_reasoning_omits_adaptive_without_effort() {
1380        assert!(build_api_reasoning(Some(&ThinkingConfig::adaptive())).is_none());
1381    }
1382
1383    #[test]
1384    fn test_openai_responses_rejects_adaptive_thinking() {
1385        let provider = OpenAIResponsesProvider::codex("test-key".to_string());
1386        let error = provider
1387            .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
1388            .unwrap_err();
1389        assert!(
1390            error
1391                .to_string()
1392                .contains("adaptive thinking is not supported")
1393        );
1394    }
1395
1396    #[test]
1397    fn test_api_tool_serialization() {
1398        let tool = ApiTool {
1399            r#type: "function".to_owned(),
1400            name: "get_weather".to_owned(),
1401            description: Some("Get weather".to_owned()),
1402            parameters: Some(serde_json::json!({"type": "object"})),
1403            strict: Some(true),
1404        };
1405
1406        let json = serde_json::to_string(&tool).unwrap();
1407        assert!(json.contains("\"type\":\"function\""));
1408        assert!(json.contains("\"name\":\"get_weather\""));
1409        assert!(json.contains("\"strict\":true"));
1410    }
1411
1412    #[test]
1413    fn test_api_response_deserialization() {
1414        let json = r#"{
1415            "id": "resp_123",
1416            "model": "gpt-5.2-codex",
1417            "output": [
1418                {
1419                    "type": "message",
1420                    "role": "assistant",
1421                    "content": [
1422                        {"type": "output_text", "text": "Hello!"}
1423                    ]
1424                }
1425            ],
1426            "status": "completed",
1427            "usage": {
1428                "input_tokens": 100,
1429                "output_tokens": 50
1430            }
1431        }"#;
1432
1433        let response: ApiResponse = serde_json::from_str(json).unwrap();
1434        assert_eq!(response.id, "resp_123");
1435        assert_eq!(response.model, "gpt-5.2-codex");
1436        assert_eq!(response.output.len(), 1);
1437    }
1438
1439    #[test]
1440    fn test_api_response_with_function_call() {
1441        let json = r#"{
1442            "id": "resp_456",
1443            "model": "gpt-5.2-codex",
1444            "output": [
1445                {
1446                    "type": "function_call",
1447                    "call_id": "call_abc",
1448                    "name": "read_file",
1449                    "arguments": "{\"path\": \"test.txt\"}"
1450                }
1451            ],
1452            "status": "completed"
1453        }"#;
1454
1455        let response: ApiResponse = serde_json::from_str(json).unwrap();
1456        assert_eq!(response.output.len(), 1);
1457
1458        match &response.output[0] {
1459            ApiOutputItem::FunctionCall {
1460                call_id,
1461                name,
1462                arguments,
1463            } => {
1464                assert_eq!(call_id, "call_abc");
1465                assert_eq!(name, "read_file");
1466                assert!(arguments.contains("test.txt"));
1467            }
1468            _ => panic!("Expected FunctionCall"),
1469        }
1470    }
1471
1472    #[test]
1473    fn test_build_content_blocks_text() {
1474        let output = vec![ApiOutputItem::Message {
1475            _role: "assistant".to_owned(),
1476            content: vec![ApiOutputContent::Text {
1477                text: "Hello!".to_owned(),
1478            }],
1479        }];
1480
1481        let blocks = build_content_blocks(&output);
1482        assert_eq!(blocks.len(), 1);
1483        assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Hello!"));
1484    }
1485
1486    #[test]
1487    fn test_build_content_blocks_function_call() {
1488        let output = vec![ApiOutputItem::FunctionCall {
1489            call_id: "call_123".to_owned(),
1490            name: "test_tool".to_owned(),
1491            arguments: r#"{"key": "value"}"#.to_owned(),
1492        }];
1493
1494        let blocks = build_content_blocks(&output);
1495        assert_eq!(blocks.len(), 1);
1496        assert!(
1497            matches!(&blocks[0], ContentBlock::ToolUse { id, name, .. } if id == "call_123" && name == "test_tool")
1498        );
1499    }
1500
1501    #[test]
1502    fn test_request_serializes_response_format_as_text_format_and_forced_tool_choice() {
1503        let req = ApiResponsesRequest {
1504            model: "gpt-5.3-codex",
1505            input: &[],
1506            tools: None,
1507            max_output_tokens: Some(1024),
1508            reasoning: None,
1509            parallel_tool_calls: None,
1510            text: Some(ApiResponseText::from(&ResponseFormat::new(
1511                "person",
1512                serde_json::json!({"type": "object"}),
1513            ))),
1514            tool_choice: Some(ApiToolChoice::from(&ToolChoice::Tool("respond".to_owned()))),
1515        };
1516
1517        let json = serde_json::to_value(&req).unwrap();
1518        assert_eq!(json["text"]["format"]["type"], "json_schema");
1519        assert_eq!(json["text"]["format"]["name"], "person");
1520        assert_eq!(json["text"]["format"]["strict"], true);
1521        assert_eq!(json["text"]["format"]["schema"]["type"], "object");
1522        assert_eq!(json["tool_choice"]["type"], "function");
1523        assert_eq!(json["tool_choice"]["name"], "respond");
1524    }
1525
1526    #[test]
1527    fn test_tool_choice_auto_serializes_as_string() {
1528        let json = serde_json::to_value(ApiToolChoice::from(&ToolChoice::Auto)).unwrap();
1529        assert_eq!(json, serde_json::json!("auto"));
1530    }
1531
1532    #[test]
1533    fn test_api_response_failed_status_carries_error_message() {
1534        let json = r#"{
1535            "id": "resp_fail",
1536            "model": "gpt-5.3-codex",
1537            "output": [],
1538            "status": "failed",
1539            "error": {"message": "model produced no output"}
1540        }"#;
1541
1542        let resp: ApiResponse = serde_json::from_str(json).unwrap();
1543        assert!(matches!(resp.status, Some(ApiStatus::Failed)));
1544        assert_eq!(
1545            resp.error.and_then(|e| e.message).as_deref(),
1546            Some("model produced no output")
1547        );
1548    }
1549
1550    #[test]
1551    fn test_flush_responses_tool_calls_assigns_distinct_ordered_indices() {
1552        let mut tool_calls = std::collections::HashMap::new();
1553        tool_calls.insert(
1554            "b".to_owned(),
1555            ToolCallAccumulator {
1556                id: "b".to_owned(),
1557                name: "second".to_owned(),
1558                arguments: "{}".to_owned(),
1559                order: 1,
1560            },
1561        );
1562        tool_calls.insert(
1563            "a".to_owned(),
1564            ToolCallAccumulator {
1565                id: "a".to_owned(),
1566                name: "first".to_owned(),
1567                arguments: "{}".to_owned(),
1568                order: 0,
1569            },
1570        );
1571
1572        let deltas = flush_responses_tool_calls(&tool_calls);
1573        let starts: Vec<(String, usize)> = deltas
1574            .iter()
1575            .filter_map(|d| match d {
1576                StreamDelta::ToolUseStart {
1577                    name, block_index, ..
1578                } => Some((name.clone(), *block_index)),
1579                _ => None,
1580            })
1581            .collect();
1582        assert_eq!(
1583            starts,
1584            vec![("first".to_owned(), 1), ("second".to_owned(), 2)]
1585        );
1586    }
1587}