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