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::{
10    SseLineBuffer, StreamBox, StreamDelta, StreamErrorKind, reqwest_body_error_delta,
11    reqwest_error_delta,
12};
13use agent_sdk_foundation::llm::{
14    ChatOutcome, ChatRequest, ChatResponse, Content, ContentBlock, ResponseFormat, SpeedTier,
15    StopReason, ThinkingConfig, ToolChoice, Usage,
16};
17use anyhow::Result;
18use async_trait::async_trait;
19use futures::StreamExt;
20use reqwest::StatusCode;
21use serde::{Deserialize, Serialize};
22
23use super::openai_reasoning::{
24    OpenAIAllowedToolsMode, OpenAIPromptCacheMode, OpenAIPromptCacheTtl, OpenAIReasoningConfig,
25    OpenAIReasoningContext, OpenAIReasoningEffort, OpenAIReasoningMode, OpenAIReasoningSummary,
26    OpenAITextVerbosity, OpenAIToolChoice, is_gpt56_model, legacy_reasoning_effort,
27    served_speed_from_service_tier, service_tier_wire_value, validate_reasoning_config,
28    validate_tool_choice,
29};
30use super::openai_schema::normalize_strict_schema;
31
32const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
33const ENCRYPTED_REASONING_INCLUDE: &[&str] = &["reasoning.encrypted_content"];
34const OPENAI_RESPONSES_STATE_PROVIDER: &str = "openai-responses";
35const OPENAI_MESSAGE_ITEM_TYPE: &str = "message";
36
37/// Build an HTTP client with connect/keepalive timeouts matching the sibling
38/// providers (`anthropic`, `vertex`). A bare `reqwest::Client::new()` has no
39/// connect timeout, so a black-holed connect would wedge `chat`/`chat_stream`
40/// forever.
41fn build_http_client() -> reqwest::Client {
42    reqwest::Client::builder()
43        .connect_timeout(std::time::Duration::from_secs(30))
44        .tcp_keepalive(std::time::Duration::from_secs(30))
45        .build()
46        .unwrap_or_default()
47}
48
49// GPT-5.6 series
50pub const MODEL_GPT56: &str = "gpt-5.6";
51pub const MODEL_GPT56_SOL: &str = "gpt-5.6-sol";
52pub const MODEL_GPT56_TERRA: &str = "gpt-5.6-terra";
53pub const MODEL_GPT56_LUNA: &str = "gpt-5.6-luna";
54
55// GPT-5.3-Codex (latest Codex model)
56pub const MODEL_GPT53_CODEX: &str = "gpt-5.3-codex";
57
58// GPT-5.2-Codex (legacy Responses-first codex model)
59pub const MODEL_GPT52_CODEX: &str = "gpt-5.2-codex";
60
61/// Reasoning effort level for the model.
62#[derive(Clone, Copy, Debug, Default, Serialize)]
63#[serde(rename_all = "lowercase")]
64pub enum ReasoningEffort {
65    Low,
66    #[default]
67    Medium,
68    High,
69    /// Extra-high reasoning for complex problems
70    #[serde(rename = "xhigh")]
71    XHigh,
72    /// GPT-5.6 maximum reasoning effort
73    Max,
74}
75
76/// `OpenAI` Responses API provider.
77///
78/// This provider uses the `/v1/responses` endpoint for `OpenAI` models that expose
79/// agentic workflows over the Responses API.
80#[derive(Clone)]
81pub struct OpenAIResponsesProvider {
82    client: reqwest::Client,
83    api_key: String,
84    model: String,
85    base_url: String,
86    thinking: Option<ThinkingConfig>,
87    reasoning: Option<OpenAIReasoningConfig>,
88    /// Priority-processing opt-in ([`OpenAIResponsesProvider::with_speed`]).
89    speed: Option<SpeedTier>,
90    /// Extra headers applied to every request (e.g. for gateway / BYOK auth).
91    extra_headers: Vec<(String, String)>,
92}
93
94impl OpenAIResponsesProvider {
95    /// Create a new `OpenAI` Responses API provider.
96    #[must_use]
97    pub fn new(api_key: String, model: String) -> Self {
98        Self {
99            client: build_http_client(),
100            api_key,
101            model,
102            base_url: DEFAULT_BASE_URL.to_owned(),
103            thinking: None,
104            reasoning: None,
105            speed: None,
106            extra_headers: Vec::new(),
107        }
108    }
109
110    /// Create a provider with a custom base URL.
111    #[must_use]
112    pub fn with_base_url(api_key: String, model: String, base_url: String) -> Self {
113        Self {
114            client: build_http_client(),
115            api_key,
116            model,
117            base_url,
118            thinking: None,
119            reasoning: None,
120            speed: None,
121            extra_headers: Vec::new(),
122        }
123    }
124
125    /// Add extra HTTP headers applied to every request.
126    ///
127    /// Used by [`OpenAIProvider`](super::openai::OpenAIProvider)'s transparent
128    /// Responses-API reroute to forward its BYOK / gateway auth headers (e.g.
129    /// `cf-aig-authorization`) so a rerouted request authenticates correctly.
130    #[must_use]
131    pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
132        self.extra_headers = headers;
133        self
134    }
135
136    /// Reuse an existing pooled `reqwest::Client` instead of building a fresh one.
137    ///
138    /// `reqwest::Client` is an `Arc` handle (cheap to clone) backed by a
139    /// connection pool; reusing it across the reroute preserves keep-alive so a
140    /// rerouted agent loop does not pay a new TCP+TLS handshake every turn.
141    #[must_use]
142    pub(crate) fn with_client(mut self, client: reqwest::Client) -> Self {
143        self.client = client;
144        self
145    }
146
147    /// Apply auth + extra headers to a request builder. Skips `Authorization`
148    /// when `api_key` is empty (BYOK gateway mode — auth is carried by
149    /// `extra_headers`).
150    fn apply_headers(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
151        let builder = if self.api_key.is_empty() {
152            builder
153        } else {
154            builder.header("Authorization", format!("Bearer {}", self.api_key))
155        };
156        self.extra_headers
157            .iter()
158            .fold(builder, |b, (k, v)| b.header(k.as_str(), v.as_str()))
159    }
160
161    /// Create a provider using the GPT-5.6 alias, which routes to GPT-5.6 Sol.
162    #[must_use]
163    pub fn gpt56(api_key: String) -> Self {
164        Self::new(api_key, MODEL_GPT56.to_owned())
165    }
166
167    /// Create a provider using GPT-5.6 Sol (frontier reasoning and coding).
168    #[must_use]
169    pub fn gpt56_sol(api_key: String) -> Self {
170        Self::new(api_key, MODEL_GPT56_SOL.to_owned())
171    }
172
173    /// Create a provider using GPT-5.6 Terra (balanced intelligence and cost).
174    #[must_use]
175    pub fn gpt56_terra(api_key: String) -> Self {
176        Self::new(api_key, MODEL_GPT56_TERRA.to_owned())
177    }
178
179    /// Create a provider using GPT-5.6 Luna (cost-sensitive, high-volume work).
180    #[must_use]
181    pub fn gpt56_luna(api_key: String) -> Self {
182        Self::new(api_key, MODEL_GPT56_LUNA.to_owned())
183    }
184
185    /// Create a provider using GPT-5.3-Codex (latest codex model).
186    #[must_use]
187    pub fn gpt53_codex(api_key: String) -> Self {
188        Self::new(api_key, MODEL_GPT53_CODEX.to_owned())
189    }
190
191    /// Create a provider using the latest Codex model.
192    #[must_use]
193    pub fn codex(api_key: String) -> Self {
194        Self::gpt53_codex(api_key)
195    }
196
197    /// Set the provider-owned thinking configuration for this model.
198    #[must_use]
199    pub fn with_thinking(mut self, thinking: ThinkingConfig) -> Self {
200        self.thinking = Some(thinking);
201        self.reasoning = None;
202        self
203    }
204
205    /// Set exact `OpenAI` reasoning and related response controls.
206    ///
207    /// Calling this after [`with_thinking`](Self::with_thinking) replaces the
208    /// legacy provider-owned thinking configuration.
209    #[must_use]
210    pub fn with_reasoning(mut self, reasoning: OpenAIReasoningConfig) -> Self {
211        self.reasoning = Some(reasoning);
212        self.thinking = None;
213        self
214    }
215
216    /// Request an inference speed tier for every request from this provider.
217    ///
218    /// See [`OpenAIProvider::with_speed`](super::openai::OpenAIProvider::with_speed)
219    /// — the Responses API takes the same `service_tier` field with the same
220    /// semantics.
221    #[must_use]
222    pub const fn with_speed(mut self, speed: SpeedTier) -> Self {
223        self.speed = Some(speed);
224        self
225    }
226
227    /// Set the reasoning effort level.
228    #[must_use]
229    pub fn with_reasoning_effort(self, effort: ReasoningEffort) -> Self {
230        let effort = match effort {
231            ReasoningEffort::Low => OpenAIReasoningEffort::Low,
232            ReasoningEffort::Medium => OpenAIReasoningEffort::Medium,
233            ReasoningEffort::High => OpenAIReasoningEffort::High,
234            ReasoningEffort::XHigh => OpenAIReasoningEffort::XHigh,
235            ReasoningEffort::Max => OpenAIReasoningEffort::Max,
236        };
237        self.with_reasoning(OpenAIReasoningConfig::new().with_effort(effort))
238    }
239
240    fn effective_max_tokens(&self, request: &ChatRequest) -> u32 {
241        if request.max_tokens_explicit {
242            request.max_tokens
243        } else {
244            self.default_max_tokens()
245        }
246    }
247
248    fn resolve_openai_reasoning(
249        &self,
250        request_thinking: Option<&ThinkingConfig>,
251    ) -> Result<Option<OpenAIReasoningConfig>> {
252        let legacy = if request_thinking.is_some() || self.reasoning.is_none() {
253            self.resolve_thinking_config(request_thinking)?
254        } else {
255            None
256        };
257
258        let config = match (self.reasoning.clone(), legacy.as_ref()) {
259            (Some(config), Some(thinking)) => {
260                Some(config.with_optional_effort(legacy_reasoning_effort(thinking)))
261            }
262            (Some(config), None) => Some(config),
263            (None, Some(thinking)) => Some(
264                OpenAIReasoningConfig::new()
265                    .with_optional_effort(legacy_reasoning_effort(thinking)),
266            ),
267            (None, None) => None,
268        };
269
270        if let Some(config) = &config {
271            validate_reasoning_config(&self.model, config)?;
272        }
273        Ok(config)
274    }
275
276    async fn send_responses_request(
277        &self,
278        api_request: &ApiResponsesRequest<'_>,
279    ) -> Result<(StatusCode, Vec<u8>, Option<std::time::Duration>)> {
280        let builder = self
281            .client
282            .post(format!("{}/responses", self.base_url))
283            .header("Content-Type", "application/json");
284        let response = self
285            .apply_headers(builder)
286            .json(api_request)
287            .send()
288            .await
289            .map_err(|error| anyhow::anyhow!("request failed: {error}"))?;
290        let status = response.status();
291        let retry_after = (status == StatusCode::TOO_MANY_REQUESTS)
292            .then(|| crate::http::retry_after_from_headers(response.headers()))
293            .flatten();
294        let bytes = response
295            .bytes()
296            .await
297            .map_err(|error| anyhow::anyhow!("failed to read response body: {error}"))?
298            .to_vec();
299        Ok((status, bytes, retry_after))
300    }
301}
302
303#[async_trait]
304impl LlmProvider for OpenAIResponsesProvider {
305    async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
306        let reasoning_config = match self.resolve_openai_reasoning(request.thinking.as_ref()) {
307            Ok(reasoning) => reasoning,
308            Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
309        };
310        if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
311            return Ok(ChatOutcome::InvalidRequest(error.to_string()));
312        }
313        if let Err(error) = validate_responses_tool_choice(
314            request.tool_choice.as_ref(),
315            reasoning_config.as_ref(),
316            request.tools.as_deref(),
317        ) {
318            return Ok(ChatOutcome::InvalidRequest(error.to_string()));
319        }
320        if let Err(error) = validate_response_format(request.response_format.as_ref()) {
321            return Ok(ChatOutcome::InvalidRequest(error.to_string()));
322        }
323        let prompt_cache =
324            match resolve_prompt_cache_plan(&self.model, &request, reasoning_config.as_ref()) {
325                Ok(plan) => plan,
326                Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
327            };
328        let reasoning = build_api_reasoning(reasoning_config.as_ref());
329        let store = reasoning_config
330            .as_ref()
331            .and_then(OpenAIReasoningConfig::store)
332            .unwrap_or(false);
333        let include_encrypted_reasoning = !store
334            && (reasoning.is_some()
335                || self
336                    .capabilities()
337                    .is_some_and(|capabilities| capabilities.supports_thinking));
338        let input = build_api_input(&request, prompt_cache.explicit_breakpoints);
339        let text = ApiResponseText::from_options(
340            request.response_format.as_ref(),
341            reasoning_config
342                .as_ref()
343                .and_then(OpenAIReasoningConfig::verbosity),
344        );
345        let prompt_cache_options = prompt_cache.options;
346        let max_tokens = self.effective_max_tokens(&request);
347        let tool_choice =
348            resolve_api_tool_choice(request.tool_choice.as_ref(), reasoning_config.as_ref());
349        let tools: Option<Vec<ApiTool>> = request
350            .tools
351            .map(|ts| ts.into_iter().map(convert_tool).collect());
352        let parallel_tool_calls = reasoning_config
353            .as_ref()
354            .and_then(OpenAIReasoningConfig::parallel_tool_calls)
355            .or_else(|| {
356                tools
357                    .as_ref()
358                    .is_some_and(|tools| !tools.is_empty())
359                    .then_some(true)
360            });
361
362        let api_request = ApiResponsesRequest {
363            model: &self.model,
364            input: &input,
365            tools: tools.as_deref(),
366            max_output_tokens: Some(max_tokens),
367            reasoning,
368            parallel_tool_calls,
369            text,
370            tool_choice,
371            prompt_cache_options,
372            store,
373            include: include_encrypted_reasoning.then_some(ENCRYPTED_REASONING_INCLUDE),
374            prompt_cache_key: request.session_id.as_deref(),
375            safety_identifier: reasoning_config
376                .as_ref()
377                .and_then(OpenAIReasoningConfig::safety_identifier),
378            service_tier: service_tier_wire_value(self.speed),
379        };
380
381        log::debug!(
382            "OpenAI Responses API request model={} max_tokens={}",
383            self.model,
384            max_tokens
385        );
386
387        let (status, bytes, retry_after) = self.send_responses_request(&api_request).await?;
388
389        log::debug!(
390            "OpenAI Responses API response status={} body_len={}",
391            status,
392            bytes.len()
393        );
394
395        if let Some(outcome) = classify_responses_status(status, &bytes, retry_after) {
396            return Ok(outcome);
397        }
398
399        let api_response: ApiResponse = serde_json::from_slice(&bytes)
400            .map_err(|e| anyhow::anyhow!("failed to parse response: {e}"))?;
401
402        Ok(build_responses_outcome(api_response))
403    }
404
405    #[allow(clippy::too_many_lines)]
406    fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
407        let served_route = self.route().to_owned();
408        Box::pin(async_stream::stream! {
409            let reasoning_config = match self.resolve_openai_reasoning(request.thinking.as_ref()) {
410                Ok(reasoning) => reasoning,
411                Err(error) => {
412                    yield Ok(StreamDelta::Error {
413                        message: error.to_string(),
414                        kind: StreamErrorKind::InvalidRequest,
415                    });
416                    return;
417                }
418            };
419            if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
420                yield Ok(StreamDelta::Error {
421                    message: error.to_string(),
422                    kind: StreamErrorKind::InvalidRequest,
423                });
424                return;
425            }
426            if let Err(error) = validate_responses_tool_choice(
427                request.tool_choice.as_ref(),
428                reasoning_config.as_ref(),
429                request.tools.as_deref(),
430            ) {
431                yield Ok(StreamDelta::Error {
432                    message: error.to_string(),
433                    kind: StreamErrorKind::InvalidRequest,
434                });
435                return;
436            }
437            if let Err(error) = validate_response_format(request.response_format.as_ref()) {
438                yield Ok(StreamDelta::Error {
439                    message: error.to_string(),
440                    kind: StreamErrorKind::InvalidRequest,
441                });
442                return;
443            }
444            let prompt_cache = match resolve_prompt_cache_plan(
445                &self.model,
446                &request,
447                reasoning_config.as_ref(),
448            ) {
449                Ok(plan) => plan,
450                Err(error) => {
451                    yield Ok(StreamDelta::Error {
452                        message: error.to_string(),
453                        kind: StreamErrorKind::InvalidRequest,
454                    });
455                    return;
456                }
457            };
458            let reasoning = build_api_reasoning(reasoning_config.as_ref());
459            let store = reasoning_config
460                .as_ref()
461                .and_then(OpenAIReasoningConfig::store)
462                .unwrap_or(false);
463            let include_encrypted_reasoning = !store
464                && (reasoning.is_some()
465                    || self
466                        .capabilities()
467                        .is_some_and(|capabilities| capabilities.supports_thinking));
468            let input = build_api_input(&request, prompt_cache.explicit_breakpoints);
469            let text = ApiResponseText::from_options(
470                request.response_format.as_ref(),
471                reasoning_config
472                    .as_ref()
473                    .and_then(OpenAIReasoningConfig::verbosity),
474            );
475            let prompt_cache_options = prompt_cache.options;
476            let max_tokens = self.effective_max_tokens(&request);
477            let tool_choice = resolve_api_tool_choice(request.tool_choice.as_ref(), reasoning_config.as_ref());
478            let tools: Option<Vec<ApiTool>> = request
479                .tools
480                .map(|ts| ts.into_iter().map(convert_tool).collect());
481            let parallel_tool_calls = reasoning_config
482                .as_ref()
483                .and_then(OpenAIReasoningConfig::parallel_tool_calls)
484                .or_else(|| tools.as_ref().is_some_and(|tools| !tools.is_empty()).then_some(true));
485
486            let api_request = ApiResponsesRequestStreaming {
487                model: &self.model,
488                input: &input,
489                tools: tools.as_deref(),
490                max_output_tokens: Some(max_tokens),
491                reasoning,
492                parallel_tool_calls,
493                text,
494                tool_choice,
495                prompt_cache_options,
496                store,
497                include: include_encrypted_reasoning
498                    .then(|| ENCRYPTED_REASONING_INCLUDE.iter().map(|value| (*value).to_owned()).collect()),
499                prompt_cache_key: request.session_id.clone(),
500                safety_identifier: reasoning_config
501                    .as_ref()
502                    .and_then(OpenAIReasoningConfig::safety_identifier)
503                    .map(ToOwned::to_owned),
504                service_tier: service_tier_wire_value(self.speed),
505                stream: true,
506            };
507
508            log::debug!("OpenAI Responses API streaming request model={} max_tokens={}", self.model, max_tokens);
509
510            let stream_builder = self.client
511                .post(format!("{}/responses", self.base_url))
512                .header("Content-Type", "application/json");
513            let response = match self
514                .apply_headers(stream_builder)
515                .json(&api_request)
516                .send()
517                .await
518            {
519                Ok(response) => response,
520                Err(error) => {
521                    yield Ok(reqwest_error_delta("request failed", &error));
522                    return;
523                }
524            };
525
526            let status = response.status();
527
528            if !status.is_success() {
529                // Headers are read before the body: `text()` consumes the response.
530                let header_hint = crate::http::retry_after_from_headers(response.headers());
531                let body = response.text().await.unwrap_or_default();
532                let kind = if status == StatusCode::TOO_MANY_REQUESTS {
533                    StreamErrorKind::RateLimited(
534                        header_hint.or_else(|| crate::retry_hints::openai_retry_delay(&body)),
535                    )
536                } else if status.is_server_error() {
537                    StreamErrorKind::ServerError
538                } else {
539                    StreamErrorKind::InvalidRequest
540                };
541                log::warn!("OpenAI Responses error status={status} body={body}");
542                yield Ok(StreamDelta::Error { message: body, kind });
543                return;
544            }
545
546            let mut sse = SseLineBuffer::new();
547            let mut stream = response.bytes_stream();
548            let mut tool_calls: std::collections::HashMap<String, ToolCallAccumulator> =
549                std::collections::HashMap::new();
550            let mut message_state_markers: std::collections::HashMap<usize, serde_json::Value> =
551                std::collections::HashMap::new();
552            let mut refused = false;
553
554            while let Some(chunk_result) = stream.next().await {
555                let chunk = match chunk_result {
556                    Ok(chunk) => chunk,
557                    Err(error) => {
558                        yield Ok(reqwest_body_error_delta("stream error", &error));
559                        return;
560                    }
561                };
562                sse.extend(&chunk);
563
564                while let Some(line) = sse.next_line() {
565                    let line = line.trim();
566                    if line.is_empty() { continue; }
567
568                    let Some(data) = line.strip_prefix("data: ") else {
569                        log::trace!("Responses SSE non-data line bytes={}", line.len());
570                        continue;
571                    };
572
573                    if data == "[DONE]" {
574                        yield Ok(StreamDelta::Error {
575                            message: "OpenAI Responses stream ended before a semantic terminal event"
576                                .to_owned(),
577                            kind: StreamErrorKind::ServerError,
578                        });
579                        return;
580                    }
581
582                    let event = match serde_json::from_str::<ApiStreamEvent>(data) {
583                        Ok(event) => event,
584                        Err(error) => {
585                            log::warn!("Failed to parse Responses SSE event: {error}");
586                            yield Ok(StreamDelta::Error {
587                                message: format!("invalid OpenAI Responses stream event: {error}"),
588                                kind: StreamErrorKind::ServerError,
589                            });
590                            return;
591                        }
592                    };
593                    log::trace!(
594                        "Responses SSE event type={} bytes={}",
595                        event.r#type,
596                        data.len()
597                    );
598
599                    match event.r#type.as_str() {
600                            // ── Content deltas ──────────────────────────
601                            "response.output_text.delta" => {
602                                if let Some(delta) = event.delta {
603                                    yield Ok(StreamDelta::TextDelta {
604                                        delta,
605                                        block_index: output_block_index(event.output_index),
606                                    });
607                                }
608                            }
609                            "response.refusal.delta" => {
610                                refused = true;
611                                if let Some(delta) = event.delta {
612                                    yield Ok(StreamDelta::TextDelta {
613                                        delta,
614                                        block_index: output_block_index(event.output_index),
615                                    });
616                                }
617                            }
618                            "response.output_item.added" => {
619                                if let Some(item) = &event.item
620                                    && let Some((block_index, marker)) =
621                                        message_state_from_output_item(item, event.output_index)
622                                    && message_state_markers.get(&block_index) != Some(&marker)
623                                {
624                                    message_state_markers.insert(block_index, marker.clone());
625                                    yield Ok(StreamDelta::OpaqueReasoning {
626                                        provider: OPENAI_RESPONSES_STATE_PROVIDER.to_owned(),
627                                        data: marker,
628                                        block_index,
629                                    });
630                                }
631                                // Register function_call items so we know
632                                // the call_id and name before deltas arrive.
633                                if let Some(item) = &event.item
634                                    && item.get("type").and_then(serde_json::Value::as_str)
635                                        == Some("function_call")
636                                    && let (Some(item_id), Some(call_id), Some(name)) =
637                                        (
638                                            json_string(item, "id"),
639                                            json_string(item, "call_id"),
640                                            json_string(item, "name"),
641                                        )
642                                {
643                                    let order = tool_calls.len();
644                                    tool_calls
645                                        .entry(item_id.to_owned())
646                                        .or_insert_with(|| ToolCallAccumulator {
647                                            id: call_id.to_owned(),
648                                            name: name.to_owned(),
649                                            arguments: String::new(),
650                                            order,
651                                            block_index: output_block_index(event.output_index),
652                                        });
653                                }
654                            }
655                            "response.output_item.done" => {
656                                if let Some(item) = event.item {
657                                    if let Some((block_index, marker)) =
658                                        message_state_from_output_item(&item, event.output_index)
659                                        && message_state_markers.get(&block_index) != Some(&marker)
660                                    {
661                                        message_state_markers.insert(block_index, marker.clone());
662                                        yield Ok(StreamDelta::OpaqueReasoning {
663                                            provider: OPENAI_RESPONSES_STATE_PROVIDER.to_owned(),
664                                            data: marker,
665                                            block_index,
666                                        });
667                                    }
668                                    match item.get("type").and_then(serde_json::Value::as_str) {
669                                        Some("reasoning") => {
670                                            yield Ok(StreamDelta::OpaqueReasoning {
671                                                provider: OPENAI_RESPONSES_STATE_PROVIDER.to_owned(),
672                                                data: item,
673                                                block_index: output_block_index(event.output_index),
674                                            });
675                                        }
676                                        Some("function_call") => {
677                                            if let (Some(item_id), Some(call_id), Some(name)) = (
678                                                json_string(&item, "id"),
679                                                json_string(&item, "call_id"),
680                                                json_string(&item, "name"),
681                                            ) {
682                                                let order = tool_calls.len();
683                                                let accumulator = tool_calls
684                                                    .entry(item_id.to_owned())
685                                                    .or_insert_with(|| ToolCallAccumulator {
686                                                        id: call_id.to_owned(),
687                                                        name: name.to_owned(),
688                                                        arguments: String::new(),
689                                                        order,
690                                                        block_index: output_block_index(
691                                                            event.output_index,
692                                                        ),
693                                                    });
694                                                if let Some(arguments) =
695                                                    json_string(&item, "arguments")
696                                                {
697                                                    arguments.clone_into(&mut accumulator.arguments);
698                                                }
699                                            }
700                                        }
701                                        _ => {}
702                                    }
703                                }
704                            }
705                            "response.function_call_arguments.delta" => {
706                                if let (Some(item_id), Some(delta)) =
707                                    (event.resolve_item_id().map(str::to_owned), event.delta)
708                                {
709                                    let order = tool_calls.len();
710                                    let acc =
711                                        tool_calls.entry(item_id.clone()).or_insert_with(|| {
712                                            ToolCallAccumulator {
713                                                id: item_id,
714                                                name: event.name.unwrap_or_default(),
715                                                arguments: String::new(),
716                                                order,
717                                                block_index: output_block_index(event.output_index),
718                                            }
719                                        });
720                                    acc.arguments.push_str(&delta);
721                                }
722                            }
723                            // ── Reasoning (thinking) deltas ─────────────
724                            "response.reasoning.delta"
725                            | "response.reasoning_summary_text.delta" => {
726                                if let Some(delta) = event.delta {
727                                    yield Ok(StreamDelta::ThinkingDelta {
728                                        delta,
729                                        block_index: reasoning_summary_block_index(
730                                            event.output_index,
731                                        ),
732                                    });
733                                }
734                            }
735                            // ── Completion / usage ──────────────────────
736                            "response.completed" => {
737                                let response = event.response;
738                                if let Some(response) = response {
739                                    let service_tier = response.service_tier;
740                                    if let Some(usage) = response.usage {
741                                        yield Ok(StreamDelta::Usage(map_usage(
742                                            Some(usage),
743                                            service_tier.as_deref(),
744                                        )));
745                                    }
746                                }
747                                let stop_reason = if refused {
748                                    StopReason::Refusal
749                                } else if !tool_calls.is_empty() {
750                                    StopReason::ToolUse
751                                } else {
752                                    StopReason::EndTurn
753                                };
754                                if stop_reason == StopReason::ToolUse {
755                                    for delta in flush_responses_tool_calls(&tool_calls) {
756                                        yield Ok(delta);
757                                    }
758                                }
759                                yield Ok(StreamDelta::Done {
760                                    stop_reason: Some(stop_reason),
761                                    served_route: Some(served_route.clone()),
762                                });
763                                return;
764                            }
765                            "response.incomplete" => {
766                                let response = event.response;
767                                let stop_reason = response
768                                    .as_ref()
769                                    .and_then(|response| response.incomplete_details.as_ref())
770                                    .and_then(|details| details.reason.as_deref())
771                                    .map_or(StopReason::Unknown, incomplete_stop_reason);
772                                if let Some(response) = response {
773                                    let service_tier = response.service_tier;
774                                    if let Some(usage) = response.usage {
775                                        yield Ok(StreamDelta::Usage(map_usage(
776                                            Some(usage),
777                                            service_tier.as_deref(),
778                                        )));
779                                    }
780                                }
781                                yield Ok(StreamDelta::Done {
782                                    stop_reason: Some(stop_reason),
783                                    served_route: Some(served_route.clone()),
784                                });
785                                return;
786                            }
787                            // ── Error ───────────────────────────────────
788                            "error" | "response.failed" => {
789                                // A failed response still reports the tokens it
790                                // burned, so the usage is emitted before the error
791                                // that ends the stream — the caller's accumulator
792                                // only ever sees deltas that were yielded.
793                                let (failed_usage, response_error, failed_tier) = event
794                                    .response
795                                    .map_or((None, None, None), |response| {
796                                        (response.usage, response.error, response.service_tier)
797                                    });
798                                let failed_usage = failed_usage
799                                    .map(|usage| map_usage(Some(usage), failed_tier.as_deref()));
800                                let (error_code, error_message) = response_error
801                                    .map_or((None, None), |error| (error.code, error.message));
802                                let code = error_code.or(event.code);
803                                let message = error_message
804                                    .or(event.message)
805                                    .unwrap_or_else(|| data.to_owned());
806                                let kind = responses_stream_error_kind(
807                                    &event.r#type,
808                                    code.as_deref(),
809                                    &message,
810                                    data,
811                                );
812                                if let Some(usage) = failed_usage {
813                                    yield Ok(StreamDelta::Usage(usage));
814                                }
815                                yield Ok(StreamDelta::Error {
816                                    message,
817                                    kind,
818                                });
819                                return;
820                            }
821                            // ── Lifecycle events (no content) ───────────
822                            "response.created"
823                            | "response.in_progress"
824                            | "response.content_part.added"
825                            | "response.content_part.done"
826                            | "response.output_text.done"
827                            | "response.refusal.done"
828                            | "response.function_call_arguments.done"
829                            | "response.reasoning.done"
830                            | "response.reasoning_summary_text.done" => {}
831                            // ── Unknown ─────────────────────────────────
832                            other => {
833                                log::debug!("Unhandled Responses SSE event type: {other}");
834                            }
835                        }
836                }
837            }
838
839            yield Ok(StreamDelta::Error {
840                message: "OpenAI Responses stream ended without completed, incomplete, or failed"
841                    .to_owned(),
842                kind: StreamErrorKind::ServerError,
843            });
844        })
845    }
846
847    async fn probe_connectivity(&self) -> bool {
848        crate::provider::probe_http_reachability(&self.client, &self.base_url).await
849    }
850
851    fn model(&self) -> &str {
852        &self.model
853    }
854
855    fn provider(&self) -> &'static str {
856        "openai-responses"
857    }
858
859    fn configured_thinking(&self) -> Option<&ThinkingConfig> {
860        self.thinking.as_ref()
861    }
862
863    fn configured_speed(&self) -> Option<SpeedTier> {
864        self.speed
865    }
866
867    /// See [`OpenAIProvider::validate_speed_tier`](super::openai::OpenAIProvider)
868    /// — eligibility is enforced by the API, not by a static model list.
869    fn validate_speed_tier(&self, _speed: Option<SpeedTier>) -> Result<()> {
870        Ok(())
871    }
872}
873
874// ============================================================================
875// Input building
876// ============================================================================
877
878fn build_api_input(request: &ChatRequest, explicit_cache_breakpoints: usize) -> Vec<ApiInputItem> {
879    let mut items = Vec::new();
880
881    if !request.system.is_empty() {
882        items.push(ApiInputItem::Message(ApiMessage {
883            role: ApiRole::System,
884            content: ApiMessageContent::Parts(vec![ApiInputContent::input_text(
885                request.system.clone(),
886            )]),
887            phase: None,
888        }));
889    }
890
891    for msg in &request.messages {
892        let role = api_role(msg.role);
893        match &msg.content {
894            Content::Text(text) => {
895                items.push(ApiInputItem::Message(ApiMessage {
896                    role,
897                    content: ApiMessageContent::Parts(vec![ApiInputContent::text(
898                        role,
899                        text.clone(),
900                    )]),
901                    phase: api_message_phase(role, false),
902                }));
903            }
904            Content::Blocks(blocks) => append_block_input(&mut items, role, blocks),
905        }
906    }
907
908    apply_explicit_cache_breakpoints(&mut items, explicit_cache_breakpoints);
909    items
910}
911
912fn append_block_input(items: &mut Vec<ApiInputItem>, role: ApiRole, blocks: &[ContentBlock]) {
913    let mut content_parts = Vec::new();
914    let mut phase = api_message_phase(
915        role,
916        blocks
917            .iter()
918            .any(|block| matches!(block, ContentBlock::ToolUse { .. })),
919    );
920
921    for block in blocks {
922        match block {
923            ContentBlock::Text { text } => {
924                content_parts.push(ApiInputContent::text(role, text.clone()));
925            }
926            ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => {}
927            ContentBlock::OpaqueReasoning { provider, data }
928                if matches!(role, ApiRole::Assistant)
929                    && is_message_state_marker(provider, data) =>
930            {
931                flush_message_parts(items, role, phase.clone(), &mut content_parts);
932                phase = data
933                    .get("phase")
934                    .and_then(serde_json::Value::as_str)
935                    .map(ToOwned::to_owned);
936            }
937            ContentBlock::OpaqueReasoning { provider, data }
938                if provider == OPENAI_RESPONSES_STATE_PROVIDER
939                    && data.get("type").and_then(serde_json::Value::as_str)
940                        == Some("reasoning") =>
941            {
942                flush_message_parts(items, role, phase.clone(), &mut content_parts);
943                items.push(ApiInputItem::Opaque(data.clone()));
944            }
945            ContentBlock::OpaqueReasoning { provider, .. } => {
946                log::warn!(
947                    "Ignoring opaque reasoning owned by provider={provider} in OpenAI Responses input"
948                );
949            }
950            ContentBlock::Image { source } => content_parts.push(ApiInputContent::Image {
951                image_url: format!("data:{};base64,{}", source.media_type, source.data),
952                prompt_cache_breakpoint: None,
953            }),
954            ContentBlock::Document { source } => content_parts.push(ApiInputContent::File {
955                filename: suggested_filename(&source.media_type),
956                file_data: format!("data:{};base64,{}", source.media_type, source.data),
957                prompt_cache_breakpoint: None,
958            }),
959            ContentBlock::ToolUse {
960                id, name, input, ..
961            } => {
962                flush_message_parts(items, role, phase.clone(), &mut content_parts);
963                items.push(ApiInputItem::FunctionCall(ApiFunctionCall::new(
964                    id.clone(),
965                    name.clone(),
966                    input.to_string(),
967                )));
968            }
969            ContentBlock::ToolResult {
970                tool_use_id,
971                content,
972                ..
973            } => {
974                flush_message_parts(items, role, phase.clone(), &mut content_parts);
975                items.push(ApiInputItem::FunctionCallOutput(
976                    ApiFunctionCallOutput::new(tool_use_id.clone(), content.clone()),
977                ));
978            }
979            _ => log::warn!("Skipping unrecognized OpenAI Responses content block"),
980        }
981    }
982
983    flush_message_parts(items, role, phase, &mut content_parts);
984}
985
986const fn api_role(role: agent_sdk_foundation::llm::Role) -> ApiRole {
987    match role {
988        agent_sdk_foundation::llm::Role::User => ApiRole::User,
989        agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
990    }
991}
992
993fn message_state_marker(role: &str, phase: Option<&str>) -> serde_json::Value {
994    let mut marker = serde_json::Map::new();
995    marker.insert(
996        "type".to_owned(),
997        serde_json::Value::String(OPENAI_MESSAGE_ITEM_TYPE.to_owned()),
998    );
999    marker.insert(
1000        "role".to_owned(),
1001        serde_json::Value::String(role.to_owned()),
1002    );
1003    if let Some(phase) = phase {
1004        marker.insert(
1005            "phase".to_owned(),
1006            serde_json::Value::String(phase.to_owned()),
1007        );
1008    }
1009    serde_json::Value::Object(marker)
1010}
1011
1012fn is_message_state_marker(provider: &str, data: &serde_json::Value) -> bool {
1013    provider == OPENAI_RESPONSES_STATE_PROVIDER
1014        && data.get("type").and_then(serde_json::Value::as_str) == Some(OPENAI_MESSAGE_ITEM_TYPE)
1015        && data.get("content").is_none()
1016}
1017
1018fn api_message_phase(role: ApiRole, has_tool_use: bool) -> Option<String> {
1019    match (role, has_tool_use) {
1020        (ApiRole::Assistant, true) => Some("commentary".to_owned()),
1021        (ApiRole::Assistant, false) => Some("final_answer".to_owned()),
1022        (ApiRole::System | ApiRole::User, _) => None,
1023    }
1024}
1025
1026fn flush_message_parts(
1027    items: &mut Vec<ApiInputItem>,
1028    role: ApiRole,
1029    phase: Option<String>,
1030    content_parts: &mut Vec<ApiInputContent>,
1031) {
1032    if content_parts.is_empty() {
1033        return;
1034    }
1035
1036    items.push(ApiInputItem::Message(ApiMessage {
1037        role,
1038        content: ApiMessageContent::Parts(std::mem::take(content_parts)),
1039        phase,
1040    }));
1041}
1042
1043fn apply_explicit_cache_breakpoints(items: &mut [ApiInputItem], max_breakpoints: usize) {
1044    if max_breakpoints == 0 {
1045        return;
1046    }
1047
1048    let mut candidates: Vec<(usize, usize)> = items
1049        .iter()
1050        .enumerate()
1051        .filter_map(|(index, item)| match item {
1052            ApiInputItem::Message(ApiMessage {
1053                content: ApiMessageContent::Parts(parts),
1054                ..
1055            }) => parts
1056                .iter()
1057                .rposition(ApiInputContent::supports_explicit_cache_breakpoint)
1058                .map(|part_index| (index, part_index)),
1059            _ => None,
1060        })
1061        .collect();
1062    let keep_from = candidates.len().saturating_sub(max_breakpoints.min(4));
1063
1064    for (item_index, part_index) in candidates.drain(keep_from..) {
1065        if let ApiInputItem::Message(ApiMessage {
1066            content: ApiMessageContent::Parts(parts),
1067            ..
1068        }) = &mut items[item_index]
1069            && let Some(part) = parts.get_mut(part_index)
1070        {
1071            part.set_explicit_cache_breakpoint();
1072        }
1073    }
1074}
1075
1076fn convert_tool(tool: agent_sdk_foundation::llm::Tool) -> ApiTool {
1077    let mut schema = tool.input_schema;
1078
1079    // Strict mode requires additionalProperties: false on all objects and
1080    // every property in required. This is incompatible with free-form object
1081    // schemas (objects with no defined properties). Detect and skip strict
1082    // for those tools.
1083    let use_strict = if normalize_strict_schema(&mut schema) {
1084        Some(true)
1085    } else {
1086        log::debug!(
1087            "Tool '{}' has free-form object schema — disabling strict mode",
1088            tool.name
1089        );
1090        None
1091    };
1092
1093    ApiTool {
1094        r#type: "function".to_owned(),
1095        name: tool.name,
1096        description: Some(tool.description),
1097        parameters: Some(schema),
1098        strict: use_strict,
1099    }
1100}
1101
1102fn validate_response_format(response_format: Option<&ResponseFormat>) -> Result<()> {
1103    let Some(response_format) = response_format.filter(|format| format.strict) else {
1104        return Ok(());
1105    };
1106
1107    let mut schema = response_format.schema.clone();
1108    if !normalize_strict_schema(&mut schema) {
1109        anyhow::bail!(
1110            "OpenAI strict structured output `{}` contains a free-form object schema",
1111            response_format.name
1112        );
1113    }
1114    Ok(())
1115}
1116
1117fn suggested_filename(media_type: &str) -> String {
1118    match media_type {
1119        "application/pdf" => "attachment.pdf".to_string(),
1120        "image/png" => "image.png".to_string(),
1121        "image/jpeg" => "image.jpg".to_string(),
1122        "image/gif" => "image.gif".to_string(),
1123        "image/webp" => "image.webp".to_string(),
1124        _ => "attachment.bin".to_string(),
1125    }
1126}
1127
1128fn build_content_blocks(output: &[ApiOutputItem]) -> Vec<ContentBlock> {
1129    let mut blocks = Vec::new();
1130
1131    for item in output {
1132        match item {
1133            ApiOutputItem::Reasoning { data } => {
1134                let mut raw = data.clone();
1135                raw.insert(
1136                    "type".to_owned(),
1137                    serde_json::Value::String("reasoning".to_owned()),
1138                );
1139                blocks.push(ContentBlock::OpaqueReasoning {
1140                    provider: "openai-responses".to_owned(),
1141                    data: serde_json::Value::Object(raw),
1142                });
1143
1144                if let Some(summaries) = data.get("summary").and_then(serde_json::Value::as_array) {
1145                    for summary in summaries {
1146                        if let Some(thinking) = summary
1147                            .get("text")
1148                            .and_then(serde_json::Value::as_str)
1149                            .filter(|text| !text.is_empty())
1150                        {
1151                            blocks.push(ContentBlock::Thinking {
1152                                thinking: thinking.to_owned(),
1153                                signature: None,
1154                            });
1155                        }
1156                    }
1157                }
1158            }
1159            ApiOutputItem::Message {
1160                role,
1161                phase,
1162                content,
1163            } => {
1164                blocks.push(ContentBlock::OpaqueReasoning {
1165                    provider: OPENAI_RESPONSES_STATE_PROVIDER.to_owned(),
1166                    data: message_state_marker(role, phase.as_deref()),
1167                });
1168                for c in content {
1169                    match c {
1170                        ApiOutputContent::Text { text }
1171                        | ApiOutputContent::Refusal { refusal: text }
1172                            if !text.is_empty() =>
1173                        {
1174                            blocks.push(ContentBlock::Text { text: text.clone() });
1175                        }
1176                        ApiOutputContent::Text { .. }
1177                        | ApiOutputContent::Refusal { .. }
1178                        | ApiOutputContent::Unknown => {}
1179                    }
1180                }
1181            }
1182            ApiOutputItem::FunctionCall {
1183                call_id,
1184                name,
1185                arguments,
1186                ..
1187            } => {
1188                let input =
1189                    serde_json::from_str(arguments).unwrap_or_else(|_| serde_json::json!({}));
1190                blocks.push(ContentBlock::ToolUse {
1191                    id: call_id.clone(),
1192                    name: name.clone(),
1193                    input,
1194                    thought_signature: None,
1195                });
1196            }
1197            ApiOutputItem::Unknown => {
1198                // Skip unknown output types
1199            }
1200        }
1201    }
1202
1203    blocks
1204}
1205
1206fn output_contains_refusal(output: &[ApiOutputItem]) -> bool {
1207    output.iter().any(|item| {
1208        matches!(
1209            item,
1210            ApiOutputItem::Message { content, .. }
1211                if content
1212                    .iter()
1213                    .any(|content| matches!(content, ApiOutputContent::Refusal { .. }))
1214        )
1215    })
1216}
1217
1218/// Classify an in-band Responses SSE error event.
1219///
1220/// A stream that opened with HTTP 200 can still fail mid-flight, and a
1221/// rate limit reported that way carries no HTTP status: the machine-readable
1222/// `code` is the only reliable signal. The message is prose and is never used
1223/// to decide the category — only to recover the retry delay `OpenAI` states in
1224/// it, since these events carry no `Retry-After` header.
1225fn responses_stream_error_kind(
1226    event_type: &str,
1227    code: Option<&str>,
1228    message: &str,
1229    raw: &str,
1230) -> StreamErrorKind {
1231    if matches!(code, Some("rate_limit_exceeded" | "rate_limit_error")) {
1232        log::warn!("Responses API rate limited (recoverable): {message}");
1233        return StreamErrorKind::RateLimited(crate::retry_hints::openai_retry_delay(message));
1234    }
1235    if is_context_window_rejection(code, message) {
1236        // Fatal request-shape error, not transient: the worker's overflow
1237        // compaction must fire instead of retrying the same oversized
1238        // payload (mirrors `openai_codex_responses::is_context_window_rejection`).
1239        log::warn!("Responses API context window exceeded (fatal): {message}");
1240        return StreamErrorKind::InvalidRequest;
1241    }
1242    if event_type == "response.failed"
1243        || code == Some("server_error")
1244        || raw.contains("server_error")
1245    {
1246        log::warn!("Responses API server error (recoverable): {message}");
1247        return StreamErrorKind::ServerError;
1248    }
1249    log::error!("Responses API error event: {message}");
1250    StreamErrorKind::InvalidRequest
1251}
1252
1253/// True when a failure code/message rejects the prompt for exceeding the
1254/// model's context window. The structured `context_length_exceeded` code is
1255/// the primary signal; the prose shapes cover wrapped or proxied failures
1256/// that drop the code. Mirrors
1257/// `openai_codex_responses::is_context_window_rejection`.
1258fn is_context_window_rejection(code: Option<&str>, message: &str) -> bool {
1259    if matches!(code, Some("context_length_exceeded")) {
1260        return true;
1261    }
1262    let lower = message.to_lowercase();
1263    lower.contains("exceeds the context window")
1264        || lower.contains("maximum context length")
1265        || lower.contains("context_length_exceeded")
1266}
1267
1268/// Classify a non-success HTTP status into an early [`ChatOutcome`].
1269///
1270/// Returns `None` when the status is a success and the body should instead be
1271/// parsed as an [`ApiResponse`].
1272fn classify_responses_status(
1273    status: StatusCode,
1274    bytes: &[u8],
1275    retry_after: Option<std::time::Duration>,
1276) -> Option<ChatOutcome> {
1277    if status == StatusCode::TOO_MANY_REQUESTS {
1278        let retry_after = retry_after
1279            .or_else(|| crate::retry_hints::openai_retry_delay(&String::from_utf8_lossy(bytes)));
1280        return Some(ChatOutcome::RateLimited(retry_after));
1281    }
1282    if status.is_server_error() {
1283        let body = String::from_utf8_lossy(bytes);
1284        log::error!("OpenAI Responses server error status={status} body={body}");
1285        return Some(ChatOutcome::ServerError(body.into_owned()));
1286    }
1287    if status.is_client_error() {
1288        let body = String::from_utf8_lossy(bytes);
1289        log::warn!("OpenAI Responses client error status={status} body={body}");
1290        return Some(ChatOutcome::InvalidRequest(body.into_owned()));
1291    }
1292    None
1293}
1294
1295/// Map a parsed Responses API body into a [`ChatOutcome`].
1296///
1297/// The Responses API reports generation failures as HTTP 200 with
1298/// `status=failed` plus an error object. That is surfaced as a server error
1299/// instead of a successful turn with empty content (mirrors the streaming
1300/// `response.failed` handling).
1301fn build_responses_outcome(api_response: ApiResponse) -> ChatOutcome {
1302    if matches!(api_response.status, Some(ApiStatus::Failed)) {
1303        let message = api_response
1304            .error
1305            .and_then(|error| error.message)
1306            .unwrap_or_else(|| "OpenAI Responses API reported status=failed".to_owned());
1307        log::error!("OpenAI Responses generation failed: {message}");
1308        return ChatOutcome::ServerError(message);
1309    }
1310
1311    let refused = output_contains_refusal(&api_response.output);
1312    let mut content = build_content_blocks(&api_response.output);
1313
1314    // Determine stop reason based on output content
1315    let has_tool_calls = content
1316        .iter()
1317        .any(|b| matches!(b, ContentBlock::ToolUse { .. }));
1318
1319    let stop_reason = if matches!(api_response.status, Some(ApiStatus::Incomplete)) {
1320        Some(
1321            api_response
1322                .incomplete_details
1323                .as_ref()
1324                .and_then(|details| details.reason.as_deref())
1325                .map_or(StopReason::Unknown, incomplete_stop_reason),
1326        )
1327    } else if refused {
1328        Some(StopReason::Refusal)
1329    } else if has_tool_calls {
1330        Some(StopReason::ToolUse)
1331    } else {
1332        api_response.status.map(|s| match s {
1333            ApiStatus::Completed => StopReason::EndTurn,
1334            // Unreachable: incomplete and failed are handled above, but map defensively.
1335            ApiStatus::Incomplete | ApiStatus::Failed => StopReason::Unknown,
1336        })
1337    };
1338
1339    if stop_reason != Some(StopReason::ToolUse) {
1340        content.retain(|block| !matches!(block, ContentBlock::ToolUse { .. }));
1341    }
1342
1343    ChatOutcome::Success(ChatResponse {
1344        id: api_response.id,
1345        content,
1346        model: api_response.model,
1347        stop_reason,
1348        usage: map_usage(api_response.usage, api_response.service_tier.as_deref()),
1349    })
1350}
1351
1352/// Convert the Responses API usage object into the SDK [`Usage`] shape.
1353///
1354/// `service_tier` lives on the response rather than inside `usage`, so it is
1355/// threaded in separately.
1356fn map_usage(usage: Option<ApiUsage>, service_tier: Option<&str>) -> Usage {
1357    let served_speed = served_speed_from_service_tier(service_tier);
1358    usage.map_or(
1359        Usage {
1360            served_speed,
1361            input_tokens: 0,
1362            output_tokens: 0,
1363            cached_input_tokens: 0,
1364            cache_creation_input_tokens: 0,
1365        },
1366        |u| Usage {
1367            served_speed,
1368            input_tokens: u.input_tokens,
1369            output_tokens: u.output_tokens,
1370            cached_input_tokens: u
1371                .input_tokens_details
1372                .as_ref()
1373                .map_or(0, |details| details.cached_tokens),
1374            cache_creation_input_tokens: u
1375                .input_tokens_details
1376                .as_ref()
1377                .map_or(0, |details| details.cache_write_tokens),
1378        },
1379    )
1380}
1381
1382fn build_api_reasoning(config: Option<&OpenAIReasoningConfig>) -> Option<ApiReasoning> {
1383    let config = config?;
1384    let reasoning = ApiReasoning {
1385        effort: config.effort(),
1386        mode: config.mode(),
1387        context: config.context(),
1388        summary: config.summary(),
1389    };
1390    reasoning.has_fields().then_some(reasoning)
1391}
1392
1393// ============================================================================
1394// Streaming helpers
1395// ============================================================================
1396
1397struct ToolCallAccumulator {
1398    id: String,
1399    name: String,
1400    arguments: String,
1401    /// Registration order, used to assign deterministic, distinct block indices
1402    /// when flushing (`HashMap` iteration order is otherwise nondeterministic).
1403    order: usize,
1404    /// Responses output-item position, expanded to leave room for summaries.
1405    block_index: usize,
1406}
1407
1408fn output_block_index(output_index: Option<usize>) -> usize {
1409    output_index.unwrap_or(0).saturating_mul(2)
1410}
1411
1412fn reasoning_summary_block_index(output_index: Option<usize>) -> usize {
1413    output_block_index(output_index).saturating_add(1)
1414}
1415
1416fn message_state_from_output_item(
1417    item: &serde_json::Value,
1418    output_index: Option<usize>,
1419) -> Option<(usize, serde_json::Value)> {
1420    (item.get("type").and_then(serde_json::Value::as_str) == Some(OPENAI_MESSAGE_ITEM_TYPE)).then(
1421        || {
1422            let role = json_string(item, "role").unwrap_or("assistant");
1423            let phase = json_string(item, "phase");
1424            (
1425                output_block_index(output_index),
1426                message_state_marker(role, phase),
1427            )
1428        },
1429    )
1430}
1431
1432fn json_string<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a str> {
1433    value.get(key).and_then(serde_json::Value::as_str)
1434}
1435
1436fn incomplete_stop_reason(reason: &str) -> StopReason {
1437    match reason {
1438        "max_output_tokens" => StopReason::MaxTokens,
1439        "content_filter" => StopReason::Refusal,
1440        "model_context_window_exceeded" => StopReason::ModelContextWindowExceeded,
1441        _ => StopReason::Unknown,
1442    }
1443}
1444
1445/// Emit accumulated tool calls as stream deltas with distinct, monotonically
1446/// increasing block indices in registration order.
1447///
1448/// The previous implementation assigned every call `block_index: 1` and iterated
1449/// `HashMap::values()`, so [`StreamAccumulator`](crate::streaming::StreamAccumulator)'s
1450/// stable sort preserved nondeterministic insertion order — multi-tool turns
1451/// replayed in different orders run to run. Sorting by registration order with a
1452/// unique index per call makes the final content-block order deterministic.
1453fn flush_responses_tool_calls(
1454    tool_calls: &std::collections::HashMap<String, ToolCallAccumulator>,
1455) -> Vec<StreamDelta> {
1456    let mut accs: Vec<&ToolCallAccumulator> = tool_calls.values().collect();
1457    accs.sort_by_key(|acc| (acc.block_index, acc.order));
1458
1459    let mut deltas = Vec::with_capacity(accs.len() * 2);
1460    for acc in accs {
1461        deltas.push(StreamDelta::ToolUseStart {
1462            id: acc.id.clone(),
1463            name: acc.name.clone(),
1464            block_index: acc.block_index,
1465            thought_signature: None,
1466        });
1467        deltas.push(StreamDelta::ToolInputDelta {
1468            id: acc.id.clone(),
1469            delta: acc.arguments.clone(),
1470            block_index: acc.block_index,
1471        });
1472    }
1473    deltas
1474}
1475
1476// ============================================================================
1477// API Request Types
1478// ============================================================================
1479
1480#[derive(Serialize)]
1481struct ApiResponsesRequest<'a> {
1482    model: &'a str,
1483    input: &'a [ApiInputItem],
1484    #[serde(skip_serializing_if = "Option::is_none")]
1485    tools: Option<&'a [ApiTool]>,
1486    #[serde(skip_serializing_if = "Option::is_none")]
1487    max_output_tokens: Option<u32>,
1488    #[serde(skip_serializing_if = "Option::is_none")]
1489    reasoning: Option<ApiReasoning>,
1490    #[serde(skip_serializing_if = "Option::is_none")]
1491    parallel_tool_calls: Option<bool>,
1492    #[serde(skip_serializing_if = "Option::is_none")]
1493    text: Option<ApiResponseText>,
1494    #[serde(skip_serializing_if = "Option::is_none")]
1495    tool_choice: Option<ApiToolChoice>,
1496    #[serde(skip_serializing_if = "Option::is_none")]
1497    prompt_cache_options: Option<ApiPromptCacheOptions>,
1498    store: bool,
1499    #[serde(skip_serializing_if = "Option::is_none")]
1500    include: Option<&'a [&'a str]>,
1501    #[serde(skip_serializing_if = "Option::is_none")]
1502    prompt_cache_key: Option<&'a str>,
1503    #[serde(skip_serializing_if = "Option::is_none")]
1504    safety_identifier: Option<&'a str>,
1505    #[serde(skip_serializing_if = "Option::is_none")]
1506    service_tier: Option<&'static str>,
1507}
1508
1509#[derive(Serialize)]
1510struct ApiResponsesRequestStreaming<'a> {
1511    model: &'a str,
1512    input: &'a [ApiInputItem],
1513    #[serde(skip_serializing_if = "Option::is_none")]
1514    tools: Option<&'a [ApiTool]>,
1515    #[serde(skip_serializing_if = "Option::is_none")]
1516    max_output_tokens: Option<u32>,
1517    #[serde(skip_serializing_if = "Option::is_none")]
1518    reasoning: Option<ApiReasoning>,
1519    #[serde(skip_serializing_if = "Option::is_none")]
1520    parallel_tool_calls: Option<bool>,
1521    #[serde(skip_serializing_if = "Option::is_none")]
1522    text: Option<ApiResponseText>,
1523    #[serde(skip_serializing_if = "Option::is_none")]
1524    tool_choice: Option<ApiToolChoice>,
1525    #[serde(skip_serializing_if = "Option::is_none")]
1526    prompt_cache_options: Option<ApiPromptCacheOptions>,
1527    store: bool,
1528    #[serde(skip_serializing_if = "Option::is_none")]
1529    include: Option<Vec<String>>,
1530    #[serde(skip_serializing_if = "Option::is_none")]
1531    prompt_cache_key: Option<String>,
1532    #[serde(skip_serializing_if = "Option::is_none")]
1533    safety_identifier: Option<String>,
1534    #[serde(skip_serializing_if = "Option::is_none")]
1535    service_tier: Option<&'static str>,
1536    stream: bool,
1537}
1538
1539#[derive(Serialize)]
1540struct ApiReasoning {
1541    #[serde(skip_serializing_if = "Option::is_none")]
1542    effort: Option<OpenAIReasoningEffort>,
1543    #[serde(skip_serializing_if = "Option::is_none")]
1544    mode: Option<OpenAIReasoningMode>,
1545    #[serde(skip_serializing_if = "Option::is_none")]
1546    context: Option<OpenAIReasoningContext>,
1547    #[serde(skip_serializing_if = "Option::is_none")]
1548    summary: Option<OpenAIReasoningSummary>,
1549}
1550
1551impl ApiReasoning {
1552    const fn has_fields(&self) -> bool {
1553        self.effort.is_some()
1554            || self.mode.is_some()
1555            || self.context.is_some()
1556            || self.summary.is_some()
1557    }
1558}
1559
1560/// Responses API structured-output wire field: `{"text": {"format": {...}}}`.
1561///
1562/// The Responses API carries JSON-schema structured output under
1563/// `text.format` (type `json_schema`), unlike Chat Completions' top-level
1564/// `response_format`.
1565#[derive(Serialize)]
1566struct ApiResponseText {
1567    #[serde(skip_serializing_if = "Option::is_none")]
1568    format: Option<ApiResponseTextFormat>,
1569    #[serde(skip_serializing_if = "Option::is_none")]
1570    verbosity: Option<OpenAITextVerbosity>,
1571}
1572
1573#[derive(Serialize)]
1574struct ApiResponseTextFormat {
1575    #[serde(rename = "type")]
1576    format_type: &'static str,
1577    name: String,
1578    schema: serde_json::Value,
1579    strict: bool,
1580}
1581
1582impl From<&ResponseFormat> for ApiResponseText {
1583    fn from(rf: &ResponseFormat) -> Self {
1584        let mut schema = rf.schema.clone();
1585        if rf.strict {
1586            let _ = normalize_strict_schema(&mut schema);
1587        }
1588        Self {
1589            format: Some(ApiResponseTextFormat {
1590                format_type: "json_schema",
1591                name: rf.name.clone(),
1592                schema,
1593                strict: rf.strict,
1594            }),
1595            verbosity: None,
1596        }
1597    }
1598}
1599
1600impl ApiResponseText {
1601    fn from_options(
1602        response_format: Option<&ResponseFormat>,
1603        verbosity: Option<OpenAITextVerbosity>,
1604    ) -> Option<Self> {
1605        if response_format.is_none() && verbosity.is_none() {
1606            return None;
1607        }
1608
1609        Some(Self {
1610            format: response_format.map(|rf| {
1611                let mut schema = rf.schema.clone();
1612                if rf.strict {
1613                    let _ = normalize_strict_schema(&mut schema);
1614                }
1615                ApiResponseTextFormat {
1616                    format_type: "json_schema",
1617                    name: rf.name.clone(),
1618                    schema,
1619                    strict: rf.strict,
1620                }
1621            }),
1622            verbosity,
1623        })
1624    }
1625}
1626
1627#[derive(Clone, Copy, Serialize)]
1628struct ApiPromptCacheOptions {
1629    #[serde(skip_serializing_if = "Option::is_none")]
1630    mode: Option<OpenAIPromptCacheMode>,
1631    #[serde(skip_serializing_if = "Option::is_none")]
1632    ttl: Option<OpenAIPromptCacheTtl>,
1633}
1634
1635impl ApiPromptCacheOptions {
1636    const fn new(
1637        mode: Option<OpenAIPromptCacheMode>,
1638        ttl: Option<OpenAIPromptCacheTtl>,
1639    ) -> Option<Self> {
1640        if mode.is_none() && ttl.is_none() {
1641            None
1642        } else {
1643            Some(Self { mode, ttl })
1644        }
1645    }
1646}
1647
1648#[derive(Clone, Copy)]
1649struct PromptCachePlan {
1650    options: Option<ApiPromptCacheOptions>,
1651    explicit_breakpoints: usize,
1652}
1653
1654fn resolve_prompt_cache_plan(
1655    model: &str,
1656    request: &ChatRequest,
1657    config: Option<&OpenAIReasoningConfig>,
1658) -> Result<PromptCachePlan> {
1659    let exact_mode = config.and_then(OpenAIReasoningConfig::prompt_cache_mode);
1660    let exact_ttl = config.and_then(OpenAIReasoningConfig::prompt_cache_ttl);
1661
1662    if !is_gpt56_model(model) && request.cache.is_some() {
1663        return Ok(PromptCachePlan {
1664            options: ApiPromptCacheOptions::new(exact_mode, exact_ttl),
1665            explicit_breakpoints: 0,
1666        });
1667    }
1668
1669    let Some(cache) = request.cache.as_ref() else {
1670        return Ok(PromptCachePlan {
1671            options: ApiPromptCacheOptions::new(exact_mode, exact_ttl),
1672            explicit_breakpoints: 0,
1673        });
1674    };
1675
1676    if let Some(ttl) = cache.ttl {
1677        anyhow::bail!(
1678            "OpenAI GPT-5.6 prompt caching supports only ttl=30m; shared cache TTL {} cannot be mapped losslessly. Use OpenAIReasoningConfig::with_prompt_cache_ttl(OpenAIPromptCacheTtl::ThirtyMinutes)",
1679            ttl.as_wire_str()
1680        );
1681    }
1682
1683    if !cache.enabled {
1684        return Ok(PromptCachePlan {
1685            options: ApiPromptCacheOptions::new(Some(OpenAIPromptCacheMode::Explicit), None),
1686            explicit_breakpoints: 0,
1687        });
1688    }
1689
1690    if let Some(max_breakpoints) = cache.max_breakpoints {
1691        return Ok(PromptCachePlan {
1692            options: ApiPromptCacheOptions::new(Some(OpenAIPromptCacheMode::Explicit), exact_ttl),
1693            explicit_breakpoints: usize::from(max_breakpoints.min(4)),
1694        });
1695    }
1696
1697    Ok(PromptCachePlan {
1698        options: ApiPromptCacheOptions::new(exact_mode, exact_ttl),
1699        explicit_breakpoints: 0,
1700    })
1701}
1702
1703/// Responses API `tool_choice` wire format.
1704///
1705/// - `"auto"` — model decides.
1706/// - `{"type": "function", "name": "<name>"}` — force a specific function.
1707#[derive(Serialize)]
1708#[serde(untagged)]
1709enum ApiToolChoice {
1710    Mode(&'static str),
1711    Function {
1712        #[serde(rename = "type")]
1713        choice_type: &'static str,
1714        name: String,
1715    },
1716    AllowedTools {
1717        #[serde(rename = "type")]
1718        choice_type: &'static str,
1719        mode: OpenAIAllowedToolsMode,
1720        tools: Vec<ApiAllowedTool>,
1721    },
1722}
1723
1724#[derive(Serialize)]
1725struct ApiAllowedTool {
1726    #[serde(rename = "type")]
1727    tool_type: &'static str,
1728    name: String,
1729}
1730
1731impl From<&ToolChoice> for ApiToolChoice {
1732    fn from(tc: &ToolChoice) -> Self {
1733        match tc {
1734            ToolChoice::Auto => Self::Mode("auto"),
1735            ToolChoice::Tool(name) => Self::Function {
1736                choice_type: "function",
1737                name: name.clone(),
1738            },
1739        }
1740    }
1741}
1742
1743impl From<&OpenAIToolChoice> for ApiToolChoice {
1744    fn from(choice: &OpenAIToolChoice) -> Self {
1745        match choice {
1746            OpenAIToolChoice::None => Self::Mode("none"),
1747            OpenAIToolChoice::Auto => Self::Mode("auto"),
1748            OpenAIToolChoice::Required => Self::Mode("required"),
1749            OpenAIToolChoice::Function(name) => Self::Function {
1750                choice_type: "function",
1751                name: name.clone(),
1752            },
1753            OpenAIToolChoice::AllowedTools { mode, tools } => Self::AllowedTools {
1754                choice_type: "allowed_tools",
1755                mode: *mode,
1756                tools: tools
1757                    .iter()
1758                    .map(|name| ApiAllowedTool {
1759                        tool_type: "function",
1760                        name: name.clone(),
1761                    })
1762                    .collect(),
1763            },
1764        }
1765    }
1766}
1767
1768fn resolve_api_tool_choice(
1769    request_choice: Option<&ToolChoice>,
1770    config: Option<&OpenAIReasoningConfig>,
1771) -> Option<ApiToolChoice> {
1772    request_choice.map(ApiToolChoice::from).or_else(|| {
1773        config
1774            .and_then(OpenAIReasoningConfig::tool_choice)
1775            .map(ApiToolChoice::from)
1776    })
1777}
1778
1779fn validate_responses_tool_choice(
1780    request_choice: Option<&ToolChoice>,
1781    config: Option<&OpenAIReasoningConfig>,
1782    tools: Option<&[agent_sdk_foundation::llm::Tool]>,
1783) -> Result<()> {
1784    if let Some(request_choice) = request_choice {
1785        if let ToolChoice::Tool(name) = request_choice
1786            && !tools
1787                .unwrap_or_default()
1788                .iter()
1789                .any(|tool| tool.name == *name)
1790        {
1791            anyhow::bail!("OpenAI tool_choice names unknown function `{name}`");
1792        }
1793        return Ok(());
1794    }
1795
1796    validate_tool_choice(config, tools)
1797}
1798
1799#[derive(Serialize)]
1800#[serde(untagged)]
1801enum ApiInputItem {
1802    Message(ApiMessage),
1803    FunctionCall(ApiFunctionCall),
1804    FunctionCallOutput(ApiFunctionCallOutput),
1805    Opaque(serde_json::Value),
1806}
1807
1808#[derive(Serialize)]
1809struct ApiMessage {
1810    role: ApiRole,
1811    content: ApiMessageContent,
1812    #[serde(skip_serializing_if = "Option::is_none")]
1813    phase: Option<String>,
1814}
1815
1816#[derive(Clone, Copy, Serialize)]
1817#[serde(rename_all = "lowercase")]
1818enum ApiRole {
1819    System,
1820    User,
1821    Assistant,
1822}
1823
1824#[derive(Serialize)]
1825#[serde(untagged)]
1826enum ApiMessageContent {
1827    Parts(Vec<ApiInputContent>),
1828}
1829
1830#[derive(Serialize)]
1831#[serde(tag = "type")]
1832enum ApiInputContent {
1833    #[serde(rename = "input_text")]
1834    InputText {
1835        text: String,
1836        #[serde(skip_serializing_if = "Option::is_none")]
1837        prompt_cache_breakpoint: Option<ApiPromptCacheBreakpoint>,
1838    },
1839    #[serde(rename = "output_text")]
1840    OutputText { text: String },
1841    #[serde(rename = "input_image")]
1842    Image {
1843        image_url: String,
1844        #[serde(skip_serializing_if = "Option::is_none")]
1845        prompt_cache_breakpoint: Option<ApiPromptCacheBreakpoint>,
1846    },
1847    #[serde(rename = "input_file")]
1848    File {
1849        filename: String,
1850        file_data: String,
1851        #[serde(skip_serializing_if = "Option::is_none")]
1852        prompt_cache_breakpoint: Option<ApiPromptCacheBreakpoint>,
1853    },
1854}
1855
1856impl ApiInputContent {
1857    const fn input_text(text: String) -> Self {
1858        Self::InputText {
1859            text,
1860            prompt_cache_breakpoint: None,
1861        }
1862    }
1863
1864    const fn text(role: ApiRole, text: String) -> Self {
1865        match role {
1866            ApiRole::Assistant => Self::OutputText { text },
1867            ApiRole::System | ApiRole::User => Self::input_text(text),
1868        }
1869    }
1870
1871    const fn supports_explicit_cache_breakpoint(&self) -> bool {
1872        matches!(
1873            self,
1874            Self::InputText { .. } | Self::Image { .. } | Self::File { .. }
1875        )
1876    }
1877
1878    const fn set_explicit_cache_breakpoint(&mut self) {
1879        let breakpoint = Some(ApiPromptCacheBreakpoint {
1880            mode: OpenAIPromptCacheMode::Explicit,
1881        });
1882        match self {
1883            Self::InputText {
1884                prompt_cache_breakpoint,
1885                ..
1886            }
1887            | Self::Image {
1888                prompt_cache_breakpoint,
1889                ..
1890            }
1891            | Self::File {
1892                prompt_cache_breakpoint,
1893                ..
1894            } => *prompt_cache_breakpoint = breakpoint,
1895            Self::OutputText { .. } => {}
1896        }
1897    }
1898}
1899
1900#[derive(Clone, Copy, Serialize)]
1901struct ApiPromptCacheBreakpoint {
1902    mode: OpenAIPromptCacheMode,
1903}
1904
1905#[derive(Serialize)]
1906struct ApiFunctionCall {
1907    r#type: &'static str,
1908    call_id: String,
1909    name: String,
1910    arguments: String,
1911}
1912
1913impl ApiFunctionCall {
1914    const fn new(call_id: String, name: String, arguments: String) -> Self {
1915        Self {
1916            r#type: "function_call",
1917            call_id,
1918            name,
1919            arguments,
1920        }
1921    }
1922}
1923
1924#[derive(Serialize)]
1925struct ApiFunctionCallOutput {
1926    r#type: &'static str,
1927    call_id: String,
1928    output: String,
1929}
1930
1931impl ApiFunctionCallOutput {
1932    const fn new(call_id: String, output: String) -> Self {
1933        Self {
1934            r#type: "function_call_output",
1935            call_id,
1936            output,
1937        }
1938    }
1939}
1940
1941#[derive(Serialize)]
1942struct ApiTool {
1943    r#type: String,
1944    name: String,
1945    #[serde(skip_serializing_if = "Option::is_none")]
1946    description: Option<String>,
1947    #[serde(skip_serializing_if = "Option::is_none")]
1948    parameters: Option<serde_json::Value>,
1949    #[serde(skip_serializing_if = "Option::is_none")]
1950    strict: Option<bool>,
1951}
1952
1953// ============================================================================
1954// API Response Types
1955// ============================================================================
1956
1957#[derive(Deserialize)]
1958struct ApiResponse {
1959    id: String,
1960    model: String,
1961    output: Vec<ApiOutputItem>,
1962    #[serde(default)]
1963    status: Option<ApiStatus>,
1964    #[serde(default)]
1965    usage: Option<ApiUsage>,
1966    #[serde(default)]
1967    error: Option<ApiResponseError>,
1968    #[serde(default)]
1969    incomplete_details: Option<ApiIncompleteDetails>,
1970    /// Tier that served the request, echoed back by the API.
1971    #[serde(default)]
1972    service_tier: Option<String>,
1973}
1974
1975#[derive(Deserialize)]
1976struct ApiResponseError {
1977    #[serde(default)]
1978    message: Option<String>,
1979    /// Machine-readable error code (e.g. `rate_limit_exceeded`), used to
1980    /// classify a failure the HTTP status cannot describe because the stream
1981    /// already opened with 200.
1982    #[serde(default)]
1983    code: Option<String>,
1984}
1985
1986#[derive(Deserialize)]
1987struct ApiIncompleteDetails {
1988    #[serde(default)]
1989    reason: Option<String>,
1990}
1991
1992#[derive(Clone, Copy, Deserialize)]
1993#[serde(rename_all = "snake_case")]
1994enum ApiStatus {
1995    Completed,
1996    Incomplete,
1997    Failed,
1998}
1999
2000#[derive(Deserialize)]
2001struct ApiUsage {
2002    input_tokens: u32,
2003    output_tokens: u32,
2004    #[serde(default)]
2005    input_tokens_details: Option<ApiInputTokensDetails>,
2006}
2007
2008#[derive(Deserialize)]
2009struct ApiInputTokensDetails {
2010    #[serde(default)]
2011    cached_tokens: u32,
2012    #[serde(default)]
2013    cache_write_tokens: u32,
2014}
2015
2016#[derive(Deserialize)]
2017#[serde(tag = "type")]
2018enum ApiOutputItem {
2019    #[serde(rename = "reasoning")]
2020    Reasoning {
2021        #[serde(flatten)]
2022        data: serde_json::Map<String, serde_json::Value>,
2023    },
2024    #[serde(rename = "message")]
2025    Message {
2026        role: String,
2027        #[serde(default)]
2028        phase: Option<String>,
2029        content: Vec<ApiOutputContent>,
2030    },
2031    #[serde(rename = "function_call")]
2032    FunctionCall {
2033        call_id: String,
2034        name: String,
2035        arguments: String,
2036    },
2037    #[serde(other)]
2038    Unknown,
2039}
2040
2041#[derive(Deserialize)]
2042#[serde(tag = "type")]
2043enum ApiOutputContent {
2044    #[serde(rename = "output_text")]
2045    Text { text: String },
2046    #[serde(rename = "refusal")]
2047    Refusal { refusal: String },
2048    #[serde(other)]
2049    Unknown,
2050}
2051
2052// ============================================================================
2053// Streaming Types
2054// ============================================================================
2055
2056#[derive(Deserialize)]
2057struct ApiStreamEvent {
2058    r#type: String,
2059    #[serde(default)]
2060    delta: Option<String>,
2061    /// Present on `output_item.added` / `output_item.done` for `function_call` items.
2062    #[serde(default)]
2063    item: Option<serde_json::Value>,
2064    /// Position of the item in the response output array.
2065    #[serde(default)]
2066    output_index: Option<usize>,
2067    /// Present on `function_call_arguments.delta`.
2068    #[serde(default)]
2069    item_id: Option<String>,
2070    /// Legacy field — some older events use `call_id` instead of `item_id`.
2071    #[serde(default)]
2072    call_id: Option<String>,
2073    #[serde(default)]
2074    name: Option<String>,
2075    #[serde(default)]
2076    code: Option<String>,
2077    #[serde(default)]
2078    message: Option<String>,
2079    #[serde(default)]
2080    response: Option<ApiStreamResponse>,
2081}
2082
2083impl ApiStreamEvent {
2084    /// Resolve the item identifier from whichever field is present.
2085    fn resolve_item_id(&self) -> Option<&str> {
2086        self.item_id
2087            .as_deref()
2088            .or(self.call_id.as_deref())
2089            .or_else(|| self.item.as_ref().and_then(|item| json_string(item, "id")))
2090    }
2091}
2092
2093#[derive(Deserialize)]
2094struct ApiStreamResponse {
2095    #[serde(default)]
2096    usage: Option<ApiUsage>,
2097    #[serde(default)]
2098    incomplete_details: Option<ApiIncompleteDetails>,
2099    #[serde(default)]
2100    error: Option<ApiResponseError>,
2101    /// Tier that served the request, echoed on the terminal stream frames the
2102    /// same way as on the non-streaming response.
2103    #[serde(default)]
2104    service_tier: Option<String>,
2105}
2106
2107// ============================================================================
2108// Tests
2109// ============================================================================
2110
2111#[cfg(test)]
2112mod tests {
2113    use super::*;
2114    use agent_sdk_foundation::llm::{CacheConfig, CacheTtl, Message};
2115    use anyhow::{Context as _, bail};
2116    use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
2117
2118    async fn stream_deltas(body: &str) -> anyhow::Result<Vec<StreamDelta>> {
2119        let server = MockServer::start().await;
2120        Mock::given(matchers::method("POST"))
2121            .and(matchers::path("/responses"))
2122            .respond_with(
2123                ResponseTemplate::new(200)
2124                    .insert_header("content-type", "text/event-stream")
2125                    .set_body_string(body),
2126            )
2127            .mount(&server)
2128            .await;
2129        let provider = OpenAIResponsesProvider::with_base_url(
2130            "test-key".to_owned(),
2131            MODEL_GPT56.to_owned(),
2132            server.uri(),
2133        );
2134        let mut stream = std::pin::pin!(provider.chat_stream(ChatRequest::new(
2135            String::new(),
2136            vec![Message::user("hello")],
2137        )));
2138        let mut deltas = Vec::new();
2139        while let Some(delta) = stream.next().await {
2140            deltas.push(delta?);
2141        }
2142        Ok(deltas)
2143    }
2144
2145    #[test]
2146    fn test_model_constant() {
2147        assert_eq!(MODEL_GPT56, "gpt-5.6");
2148        assert_eq!(MODEL_GPT56_SOL, "gpt-5.6-sol");
2149        assert_eq!(MODEL_GPT56_TERRA, "gpt-5.6-terra");
2150        assert_eq!(MODEL_GPT56_LUNA, "gpt-5.6-luna");
2151        assert_eq!(MODEL_GPT53_CODEX, "gpt-5.3-codex");
2152        assert_eq!(MODEL_GPT52_CODEX, "gpt-5.2-codex");
2153    }
2154
2155    #[test]
2156    fn test_gpt56_factories_create_expected_providers() {
2157        for (provider, expected_model) in [
2158            (
2159                OpenAIResponsesProvider::gpt56("test-key".to_string()),
2160                MODEL_GPT56,
2161            ),
2162            (
2163                OpenAIResponsesProvider::gpt56_sol("test-key".to_string()),
2164                MODEL_GPT56_SOL,
2165            ),
2166            (
2167                OpenAIResponsesProvider::gpt56_terra("test-key".to_string()),
2168                MODEL_GPT56_TERRA,
2169            ),
2170            (
2171                OpenAIResponsesProvider::gpt56_luna("test-key".to_string()),
2172                MODEL_GPT56_LUNA,
2173            ),
2174        ] {
2175            assert_eq!(provider.model(), expected_model);
2176            assert_eq!(provider.provider(), "openai-responses");
2177            assert_eq!(provider.default_max_tokens(), 128_000);
2178        }
2179    }
2180
2181    #[test]
2182    fn test_codex_factory() {
2183        let provider = OpenAIResponsesProvider::codex("test-key".to_string());
2184        assert_eq!(provider.model(), MODEL_GPT53_CODEX);
2185        assert_eq!(provider.provider(), "openai-responses");
2186    }
2187
2188    #[test]
2189    fn test_gpt53_codex_factory() {
2190        let provider = OpenAIResponsesProvider::gpt53_codex("test-key".to_string());
2191        assert_eq!(provider.model(), MODEL_GPT53_CODEX);
2192        assert_eq!(provider.provider(), "openai-responses");
2193    }
2194
2195    #[test]
2196    fn test_reasoning_effort_serialization() -> anyhow::Result<()> {
2197        let low = serde_json::to_string(&ReasoningEffort::Low)?;
2198        assert_eq!(low, "\"low\"");
2199
2200        let xhigh = serde_json::to_string(&ReasoningEffort::XHigh)?;
2201        assert_eq!(xhigh, "\"xhigh\"");
2202        Ok(())
2203    }
2204
2205    #[test]
2206    fn test_with_reasoning_effort() {
2207        let provider = OpenAIResponsesProvider::codex("test-key".to_string())
2208            .with_reasoning_effort(ReasoningEffort::High);
2209        assert!(provider.thinking.is_none());
2210        assert_eq!(
2211            provider
2212                .reasoning
2213                .as_ref()
2214                .and_then(OpenAIReasoningConfig::effort),
2215            Some(OpenAIReasoningEffort::High)
2216        );
2217    }
2218
2219    #[test]
2220    fn test_build_api_reasoning_uses_exact_controls() -> anyhow::Result<()> {
2221        let config = OpenAIReasoningConfig::new()
2222            .with_effort(OpenAIReasoningEffort::Max)
2223            .with_mode(OpenAIReasoningMode::Pro)
2224            .with_context(OpenAIReasoningContext::AllTurns)
2225            .with_summary(OpenAIReasoningSummary::Detailed);
2226        let reasoning = build_api_reasoning(Some(&config))
2227            .ok_or_else(|| anyhow::anyhow!("reasoning config was omitted"))?;
2228        let json = serde_json::to_value(reasoning)?;
2229        assert_eq!(json["effort"], "max");
2230        assert_eq!(json["mode"], "pro");
2231        assert_eq!(json["context"], "all_turns");
2232        assert_eq!(json["summary"], "detailed");
2233        Ok(())
2234    }
2235
2236    #[test]
2237    fn test_build_api_reasoning_omits_adaptive_without_effort() {
2238        let config = OpenAIReasoningConfig::new()
2239            .with_optional_effort(legacy_reasoning_effort(&ThinkingConfig::adaptive()));
2240        assert!(build_api_reasoning(Some(&config)).is_none());
2241    }
2242
2243    #[test]
2244    fn exact_and_legacy_provider_configuration_are_last_call_wins() {
2245        let exact = OpenAIReasoningConfig::new().with_effort(OpenAIReasoningEffort::Max);
2246        let legacy = ThinkingConfig::new(8_192);
2247
2248        let provider = OpenAIResponsesProvider::gpt56("test-key".to_string())
2249            .with_thinking(legacy.clone())
2250            .with_reasoning(exact.clone());
2251        assert!(provider.thinking.is_none());
2252        assert_eq!(provider.reasoning, Some(exact));
2253
2254        let provider = provider.with_thinking(legacy);
2255        assert!(provider.reasoning.is_none());
2256        assert!(provider.thinking.is_some());
2257    }
2258
2259    #[test]
2260    fn effective_max_tokens_uses_model_default_unless_explicit() {
2261        let provider = OpenAIResponsesProvider::gpt56("test-key".to_string());
2262        let implicit = ChatRequest::new(String::new(), vec![]);
2263        assert_eq!(provider.effective_max_tokens(&implicit), 128_000);
2264
2265        let explicit = implicit.with_max_tokens(8_192);
2266        assert_eq!(provider.effective_max_tokens(&explicit), 8_192);
2267    }
2268
2269    #[test]
2270    fn test_openai_responses_accepts_adaptive_thinking_for_codex() {
2271        let provider = OpenAIResponsesProvider::codex("test-key".to_string());
2272        assert!(
2273            provider
2274                .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
2275                .is_ok()
2276        );
2277    }
2278
2279    #[test]
2280    fn test_api_tool_serialization() {
2281        let tool = ApiTool {
2282            r#type: "function".to_owned(),
2283            name: "get_weather".to_owned(),
2284            description: Some("Get weather".to_owned()),
2285            parameters: Some(serde_json::json!({"type": "object"})),
2286            strict: Some(true),
2287        };
2288
2289        let json = serde_json::to_string(&tool).unwrap();
2290        assert!(json.contains("\"type\":\"function\""));
2291        assert!(json.contains("\"name\":\"get_weather\""));
2292        assert!(json.contains("\"strict\":true"));
2293    }
2294
2295    #[test]
2296    fn test_api_response_deserialization() {
2297        let json = r#"{
2298            "id": "resp_123",
2299            "model": "gpt-5.2-codex",
2300            "output": [
2301                {
2302                    "type": "message",
2303                    "role": "assistant",
2304                    "content": [
2305                        {"type": "output_text", "text": "Hello!"}
2306                    ]
2307                }
2308            ],
2309            "status": "completed",
2310            "usage": {
2311                "input_tokens": 100,
2312                "output_tokens": 50
2313            }
2314        }"#;
2315
2316        let response: ApiResponse = serde_json::from_str(json).unwrap();
2317        assert_eq!(response.id, "resp_123");
2318        assert_eq!(response.model, "gpt-5.2-codex");
2319        assert_eq!(response.output.len(), 1);
2320    }
2321
2322    #[test]
2323    fn test_map_usage_preserves_cache_read_and_write_tokens() -> anyhow::Result<()> {
2324        let api_usage: ApiUsage = serde_json::from_value(serde_json::json!({
2325            "input_tokens": 42,
2326            "output_tokens": 7,
2327            "input_tokens_details": {
2328                "cached_tokens": 10,
2329                "cache_write_tokens": 6
2330            }
2331        }))?;
2332
2333        let usage = map_usage(Some(api_usage), None);
2334        assert_eq!(usage.input_tokens, 42);
2335        assert_eq!(usage.output_tokens, 7);
2336        assert_eq!(usage.cached_input_tokens, 10);
2337        assert_eq!(usage.cache_creation_input_tokens, 6);
2338        Ok(())
2339    }
2340
2341    #[test]
2342    fn test_api_response_with_function_call() {
2343        let json = r#"{
2344            "id": "resp_456",
2345            "model": "gpt-5.2-codex",
2346            "output": [
2347                {
2348                    "type": "function_call",
2349                    "call_id": "call_abc",
2350                    "name": "read_file",
2351                    "arguments": "{\"path\": \"test.txt\"}"
2352                }
2353            ],
2354            "status": "completed"
2355        }"#;
2356
2357        let response: ApiResponse = serde_json::from_str(json).unwrap();
2358        assert_eq!(response.output.len(), 1);
2359
2360        match &response.output[0] {
2361            ApiOutputItem::FunctionCall {
2362                call_id,
2363                name,
2364                arguments,
2365            } => {
2366                assert_eq!(call_id, "call_abc");
2367                assert_eq!(name, "read_file");
2368                assert!(arguments.contains("test.txt"));
2369            }
2370            _ => panic!("Expected FunctionCall"),
2371        }
2372    }
2373
2374    #[test]
2375    fn test_build_content_blocks_text() {
2376        let output = vec![ApiOutputItem::Message {
2377            role: "assistant".to_owned(),
2378            phase: Some("final_answer".to_owned()),
2379            content: vec![ApiOutputContent::Text {
2380                text: "Hello!".to_owned(),
2381            }],
2382        }];
2383
2384        let blocks = build_content_blocks(&output);
2385        assert_eq!(blocks.len(), 2);
2386        assert!(matches!(
2387            &blocks[0],
2388            ContentBlock::OpaqueReasoning { data, .. }
2389                if data["phase"] == "final_answer"
2390        ));
2391        assert!(matches!(&blocks[1], ContentBlock::Text { text } if text == "Hello!"));
2392    }
2393
2394    #[test]
2395    fn test_build_content_blocks_function_call() {
2396        let output = vec![ApiOutputItem::FunctionCall {
2397            call_id: "call_123".to_owned(),
2398            name: "test_tool".to_owned(),
2399            arguments: r#"{"key": "value"}"#.to_owned(),
2400        }];
2401
2402        let blocks = build_content_blocks(&output);
2403        assert_eq!(blocks.len(), 1);
2404        assert!(
2405            matches!(&blocks[0], ContentBlock::ToolUse { id, name, .. } if id == "call_123" && name == "test_tool")
2406        );
2407    }
2408
2409    #[test]
2410    fn test_request_serializes_response_format_as_text_format_and_forced_tool_choice() {
2411        let req = ApiResponsesRequest {
2412            model: "gpt-5.3-codex",
2413            input: &[],
2414            tools: None,
2415            max_output_tokens: Some(1024),
2416            reasoning: None,
2417            parallel_tool_calls: None,
2418            text: Some(ApiResponseText::from(&ResponseFormat::new(
2419                "person",
2420                serde_json::json!({"type": "object", "properties": {}}),
2421            ))),
2422            tool_choice: Some(ApiToolChoice::from(&ToolChoice::Tool("respond".to_owned()))),
2423            prompt_cache_options: None,
2424            store: false,
2425            include: None,
2426            prompt_cache_key: None,
2427            safety_identifier: None,
2428            service_tier: None,
2429        };
2430
2431        let json = serde_json::to_value(&req).unwrap();
2432        assert_eq!(json["text"]["format"]["type"], "json_schema");
2433        assert_eq!(json["text"]["format"]["name"], "person");
2434        assert_eq!(json["text"]["format"]["strict"], true);
2435        assert_eq!(json["text"]["format"]["schema"]["type"], "object");
2436        assert_eq!(json["tool_choice"]["type"], "function");
2437        assert_eq!(json["tool_choice"]["name"], "respond");
2438    }
2439
2440    #[test]
2441    fn test_tool_choice_auto_serializes_as_string() {
2442        let json = serde_json::to_value(ApiToolChoice::from(&ToolChoice::Auto)).unwrap();
2443        assert_eq!(json, serde_json::json!("auto"));
2444    }
2445
2446    #[test]
2447    fn test_api_response_failed_status_carries_error_message() {
2448        let json = r#"{
2449            "id": "resp_fail",
2450            "model": "gpt-5.3-codex",
2451            "output": [],
2452            "status": "failed",
2453            "error": {"message": "model produced no output"}
2454        }"#;
2455
2456        let resp: ApiResponse = serde_json::from_str(json).unwrap();
2457        assert!(matches!(resp.status, Some(ApiStatus::Failed)));
2458        assert_eq!(
2459            resp.error.and_then(|e| e.message).as_deref(),
2460            Some("model produced no output")
2461        );
2462    }
2463
2464    #[test]
2465    fn test_flush_responses_tool_calls_assigns_distinct_ordered_indices() {
2466        let mut tool_calls = std::collections::HashMap::new();
2467        tool_calls.insert(
2468            "b".to_owned(),
2469            ToolCallAccumulator {
2470                id: "b".to_owned(),
2471                name: "second".to_owned(),
2472                arguments: "{}".to_owned(),
2473                order: 1,
2474                block_index: 4,
2475            },
2476        );
2477        tool_calls.insert(
2478            "a".to_owned(),
2479            ToolCallAccumulator {
2480                id: "a".to_owned(),
2481                name: "first".to_owned(),
2482                arguments: "{}".to_owned(),
2483                order: 0,
2484                block_index: 2,
2485            },
2486        );
2487
2488        let deltas = flush_responses_tool_calls(&tool_calls);
2489        let starts: Vec<(String, usize)> = deltas
2490            .iter()
2491            .filter_map(|d| match d {
2492                StreamDelta::ToolUseStart {
2493                    name, block_index, ..
2494                } => Some((name.clone(), *block_index)),
2495                _ => None,
2496            })
2497            .collect();
2498        assert_eq!(
2499            starts,
2500            vec![("first".to_owned(), 2), ("second".to_owned(), 4)]
2501        );
2502    }
2503
2504    #[test]
2505    fn input_preserves_reasoning_and_tool_item_order() -> anyhow::Result<()> {
2506        let reasoning = serde_json::json!({
2507            "type": "reasoning",
2508            "id": "rs_1",
2509            "encrypted_content": "opaque-ciphertext",
2510            "summary": []
2511        });
2512        let request = ChatRequest::new(
2513            "system",
2514            vec![Message::assistant_with_content(vec![
2515                ContentBlock::Text {
2516                    text: "before".to_owned(),
2517                },
2518                ContentBlock::OpaqueReasoning {
2519                    provider: "openai-responses".to_owned(),
2520                    data: reasoning.clone(),
2521                },
2522                ContentBlock::ToolUse {
2523                    id: "call_1".to_owned(),
2524                    name: "lookup".to_owned(),
2525                    input: serde_json::json!({"q": "value"}),
2526                    thought_signature: None,
2527                },
2528                ContentBlock::Text {
2529                    text: "after".to_owned(),
2530                },
2531            ])],
2532        );
2533
2534        let json = serde_json::to_value(build_api_input(&request, 0))?;
2535        let items = json
2536            .as_array()
2537            .context("input must serialize as an array")?;
2538        assert_eq!(items.len(), 5);
2539        assert_eq!(items[0]["role"], "system");
2540        assert_eq!(items[1]["content"][0]["text"], "before");
2541        assert_eq!(items[1]["phase"], "commentary");
2542        assert_eq!(items[2], reasoning);
2543        assert_eq!(items[3]["type"], "function_call");
2544        assert_eq!(items[4]["content"][0]["text"], "after");
2545        Ok(())
2546    }
2547
2548    #[test]
2549    fn assistant_message_phases_round_trip_without_duplicate_text() -> anyhow::Result<()> {
2550        let api_response: ApiResponse = serde_json::from_value(serde_json::json!({
2551            "id": "resp_phases",
2552            "model": "gpt-5.6",
2553            "status": "completed",
2554            "output": [
2555                {
2556                    "type": "message",
2557                    "role": "assistant",
2558                    "phase": "commentary",
2559                    "content": [{"type": "output_text", "text": "Working."}]
2560                },
2561                {
2562                    "type": "message",
2563                    "role": "assistant",
2564                    "phase": "final_answer",
2565                    "content": [{"type": "output_text", "text": "Done."}]
2566                }
2567            ]
2568        }))?;
2569        let ChatOutcome::Success(response) = build_responses_outcome(api_response) else {
2570            bail!("expected a successful response")
2571        };
2572        let request = ChatRequest::new(
2573            String::new(),
2574            vec![Message::assistant_with_content(response.content)],
2575        );
2576        let value = serde_json::to_value(build_api_input(&request, 0))?;
2577        let items = value.as_array().context("input must be an array")?;
2578
2579        assert_eq!(items.len(), 2);
2580        assert_eq!(items[0]["phase"], "commentary");
2581        assert_eq!(items[0]["content"][0]["text"], "Working.");
2582        assert_eq!(items[1]["phase"], "final_answer");
2583        assert_eq!(items[1]["content"][0]["text"], "Done.");
2584        assert_eq!(value.to_string().matches("Working.").count(), 1);
2585        assert_eq!(value.to_string().matches("Done.").count(), 1);
2586        Ok(())
2587    }
2588
2589    #[tokio::test]
2590    async fn streamed_message_phase_marker_precedes_and_round_trips_text() -> anyhow::Result<()> {
2591        let body = concat!(
2592            "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"phase\":\"commentary\",\"content\":[]}}\n\n",
2593            "data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"delta\":\"Working.\"}\n\n",
2594            "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"phase\":\"commentary\",\"content\":[{\"type\":\"output_text\",\"text\":\"Working.\"}]}}\n\n",
2595            "data: {\"type\":\"response.completed\",\"response\":{}}\n\n",
2596        );
2597        let deltas = stream_deltas(body).await?;
2598        let marker_position = deltas
2599            .iter()
2600            .position(|delta| matches!(delta, StreamDelta::OpaqueReasoning { data, .. } if data["type"] == "message"))
2601            .context("stream must contain a message phase marker")?;
2602        let text_position = deltas
2603            .iter()
2604            .position(|delta| matches!(delta, StreamDelta::TextDelta { .. }))
2605            .context("stream must contain text")?;
2606        assert!(marker_position < text_position);
2607
2608        let mut accumulator = crate::streaming::StreamAccumulator::new();
2609        for delta in &deltas {
2610            accumulator.apply(delta);
2611        }
2612        let request = ChatRequest::new(
2613            String::new(),
2614            vec![Message::assistant_with_content(
2615                accumulator.into_content_blocks(),
2616            )],
2617        );
2618        let value = serde_json::to_value(build_api_input(&request, 0))?;
2619        let items = value.as_array().context("input must be an array")?;
2620        assert_eq!(items.len(), 1);
2621        assert_eq!(items[0]["phase"], "commentary");
2622        assert_eq!(items[0]["content"][0]["text"], "Working.");
2623        assert_eq!(value.to_string().matches("Working.").count(), 1);
2624        Ok(())
2625    }
2626
2627    #[test]
2628    fn output_preserves_opaque_reasoning_summary_and_refusal() -> anyhow::Result<()> {
2629        let raw_reasoning = serde_json::json!({
2630            "type": "reasoning",
2631            "id": "rs_1",
2632            "encrypted_content": "opaque-ciphertext",
2633            "summary": [{"type": "summary_text", "text": "Checked constraints."}],
2634            "future_field": {"kept": true}
2635        });
2636        let response: ApiResponse = serde_json::from_value(serde_json::json!({
2637            "id": "resp_1",
2638            "model": "gpt-5.6",
2639            "status": "completed",
2640            "output": [
2641                raw_reasoning,
2642                {
2643                    "type": "message",
2644                    "role": "assistant",
2645                    "content": [{"type": "refusal", "refusal": "I cannot help with that."}]
2646                }
2647            ]
2648        }))?;
2649
2650        let ChatOutcome::Success(response) = build_responses_outcome(response) else {
2651            bail!("expected a successful protocol response carrying a refusal")
2652        };
2653        assert_eq!(response.stop_reason, Some(StopReason::Refusal));
2654        assert!(matches!(
2655            &response.content[0],
2656            ContentBlock::OpaqueReasoning { provider, data }
2657                if provider == "openai-responses"
2658                    && data["encrypted_content"] == "opaque-ciphertext"
2659                    && data["future_field"]["kept"] == true
2660        ));
2661        assert!(matches!(
2662            &response.content[1],
2663            ContentBlock::Thinking { thinking, .. } if thinking == "Checked constraints."
2664        ));
2665        assert!(matches!(
2666            &response.content[2],
2667            ContentBlock::OpaqueReasoning { data, .. }
2668                if data["type"] == "message"
2669        ));
2670        assert!(matches!(
2671            &response.content[3],
2672            ContentBlock::Text { text } if text == "I cannot help with that."
2673        ));
2674        Ok(())
2675    }
2676
2677    #[test]
2678    fn incomplete_reason_is_not_misreported_as_successful_tool_use() -> anyhow::Result<()> {
2679        let response: ApiResponse = serde_json::from_value(serde_json::json!({
2680            "id": "resp_incomplete",
2681            "model": "gpt-5.6",
2682            "status": "incomplete",
2683            "incomplete_details": {"reason": "max_output_tokens"},
2684            "output": [{
2685                "type": "function_call",
2686                "call_id": "call_partial",
2687                "name": "lookup",
2688                "arguments": "{"
2689            }]
2690        }))?;
2691
2692        let ChatOutcome::Success(response) = build_responses_outcome(response) else {
2693            bail!("expected an incomplete response")
2694        };
2695        assert_eq!(response.stop_reason, Some(StopReason::MaxTokens));
2696        assert!(
2697            !response
2698                .content
2699                .iter()
2700                .any(|block| matches!(block, ContentBlock::ToolUse { .. }))
2701        );
2702        Ok(())
2703    }
2704
2705    #[test]
2706    fn refusal_suppresses_partial_tool_calls() -> anyhow::Result<()> {
2707        let response: ApiResponse = serde_json::from_value(serde_json::json!({
2708            "id": "resp_refusal",
2709            "model": "gpt-5.6",
2710            "status": "completed",
2711            "output": [
2712                {
2713                    "type": "function_call",
2714                    "call_id": "call_partial",
2715                    "name": "lookup",
2716                    "arguments": "{}"
2717                },
2718                {
2719                    "type": "message",
2720                    "role": "assistant",
2721                    "phase": "final_answer",
2722                    "content": [{"type": "refusal", "refusal": "Cannot comply."}]
2723                }
2724            ]
2725        }))?;
2726
2727        let ChatOutcome::Success(response) = build_responses_outcome(response) else {
2728            bail!("expected a refusal response")
2729        };
2730        assert_eq!(response.stop_reason, Some(StopReason::Refusal));
2731        assert!(
2732            !response
2733                .content
2734                .iter()
2735                .any(|block| matches!(block, ContentBlock::ToolUse { .. }))
2736        );
2737        Ok(())
2738    }
2739
2740    #[test]
2741    fn prompt_cache_plan_maps_exact_controls_and_rejects_anthropic_ttls() -> anyhow::Result<()> {
2742        let exact = OpenAIReasoningConfig::new()
2743            .with_prompt_cache_mode(OpenAIPromptCacheMode::Explicit)
2744            .with_prompt_cache_ttl(OpenAIPromptCacheTtl::ThirtyMinutes);
2745        let request = ChatRequest::new(String::new(), vec![Message::user("hello")]);
2746        let plan = resolve_prompt_cache_plan(MODEL_GPT56, &request, Some(&exact))?;
2747        assert_eq!(plan.explicit_breakpoints, 0);
2748        let options = plan.options.context("cache options missing")?;
2749        let json = serde_json::to_value(options)?;
2750        assert_eq!(json["mode"], "explicit");
2751        assert_eq!(json["ttl"], "30m");
2752
2753        let generic_request = request
2754            .clone()
2755            .with_cache(CacheConfig::enabled().with_max_breakpoints(4));
2756        let generic_plan = resolve_prompt_cache_plan(MODEL_GPT56, &generic_request, Some(&exact))?;
2757        assert_eq!(generic_plan.explicit_breakpoints, 4);
2758
2759        let incompatible = request.with_cache(CacheConfig::enabled().with_ttl(CacheTtl::OneHour));
2760        let error = resolve_prompt_cache_plan(MODEL_GPT56, &incompatible, Some(&exact))
2761            .err()
2762            .context("Anthropic-only TTL should be rejected")?;
2763        assert!(error.to_string().contains("cannot be mapped losslessly"));
2764
2765        let legacy_plan = resolve_prompt_cache_plan(MODEL_GPT53_CODEX, &incompatible, None)?;
2766        assert!(legacy_plan.options.is_none());
2767        assert_eq!(legacy_plan.explicit_breakpoints, 0);
2768        Ok(())
2769    }
2770
2771    #[test]
2772    fn explicit_cache_breakpoints_are_capped_and_applied_to_the_tail() -> anyhow::Result<()> {
2773        let request = ChatRequest::new(
2774            "system",
2775            vec![
2776                Message::user("one"),
2777                Message::assistant("two"),
2778                Message::user("three"),
2779                Message::assistant("four"),
2780                Message::user("five"),
2781            ],
2782        );
2783        let json = serde_json::to_string(&build_api_input(&request, 9))?;
2784        assert_eq!(json.matches("prompt_cache_breakpoint").count(), 4);
2785        assert!(json.contains("\"text\":\"one\",\"prompt_cache_breakpoint\""));
2786        assert!(json.contains("\"text\":\"five\",\"prompt_cache_breakpoint\""));
2787        assert!(!json.contains("\"text\":\"two\",\"prompt_cache_breakpoint\""));
2788        assert!(!json.contains("\"text\":\"four\",\"prompt_cache_breakpoint\""));
2789        Ok(())
2790    }
2791
2792    #[test]
2793    fn explicit_cache_breakpoints_only_annotate_input_parts() -> anyhow::Result<()> {
2794        let request = ChatRequest::new(
2795            "system",
2796            vec![
2797                Message::assistant("assistant output"),
2798                Message::user("user input"),
2799                Message::assistant("final output"),
2800            ],
2801        );
2802        let value = serde_json::to_value(build_api_input(&request, 4))?;
2803        let items = value.as_array().context("input must be an array")?;
2804        let mut marker_count = 0;
2805
2806        for part in items
2807            .iter()
2808            .filter_map(|item| item.get("content").and_then(serde_json::Value::as_array))
2809            .flatten()
2810        {
2811            if part.get("prompt_cache_breakpoint").is_some() {
2812                marker_count += 1;
2813                assert!(matches!(
2814                    part.get("type").and_then(serde_json::Value::as_str),
2815                    Some("input_text" | "input_image" | "input_file")
2816                ));
2817            }
2818            if part.get("type").and_then(serde_json::Value::as_str) == Some("output_text") {
2819                assert!(part.get("prompt_cache_breakpoint").is_none());
2820            }
2821        }
2822
2823        assert_eq!(marker_count, 2);
2824        Ok(())
2825    }
2826
2827    #[test]
2828    fn strict_response_format_rejects_free_form_objects() {
2829        let format = ResponseFormat::new("freeform", serde_json::json!({"type": "object"}));
2830        assert!(validate_response_format(Some(&format)).is_err());
2831    }
2832
2833    #[test]
2834    fn allowed_tools_serializes_complete_responses_policy() -> anyhow::Result<()> {
2835        let choice = OpenAIToolChoice::AllowedTools {
2836            mode: OpenAIAllowedToolsMode::Required,
2837            tools: vec!["lookup".to_owned(), "search".to_owned()],
2838        };
2839        let json = serde_json::to_value(ApiToolChoice::from(&choice))?;
2840        assert_eq!(json["type"], "allowed_tools");
2841        assert_eq!(json["mode"], "required");
2842        assert_eq!(json["tools"][0]["type"], "function");
2843        assert_eq!(json["tools"][0]["name"], "lookup");
2844        Ok(())
2845    }
2846
2847    #[tokio::test]
2848    async fn in_band_rate_limit_event_is_recoverable_and_keeps_its_hint() -> anyhow::Result<()> {
2849        // The stream opened 200 and only then hit the quota, so the HTTP-status
2850        // branch never runs: the delay exists solely in the event's message.
2851        let body = concat!(
2852            "data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"delta\":\"Hi\"}\n\n",
2853            "data: {\"type\":\"error\",\"code\":\"rate_limit_exceeded\",\"message\":\"Rate limit reached for gpt-5.6 in organization org-x on tokens per min. Please try again in 20s.\"}\n\n",
2854        );
2855        let deltas = stream_deltas(body).await?;
2856        let kind = deltas
2857            .iter()
2858            .find_map(|delta| match delta {
2859                StreamDelta::Error { kind, .. } => Some(*kind),
2860                _ => None,
2861            })
2862            .context("expected a stream error delta")?;
2863
2864        assert_eq!(
2865            kind,
2866            StreamErrorKind::RateLimited(Some(std::time::Duration::from_secs(20))),
2867            "an in-band rate limit must be retriable and carry its parsed delay"
2868        );
2869        assert!(kind.is_recoverable());
2870        Ok(())
2871    }
2872
2873    #[tokio::test]
2874    async fn in_band_rate_limit_on_response_failed_is_not_a_server_error() -> anyhow::Result<()> {
2875        // `response.failed` defaults to ServerError; a rate-limit code inside it
2876        // must still classify as a rate limit so the hint is not discarded.
2877        let body = "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"Please try again in 1.5s\"}}}\n\n";
2878        let deltas = stream_deltas(body).await?;
2879        let kind = deltas
2880            .iter()
2881            .find_map(|delta| match delta {
2882                StreamDelta::Error { kind, .. } => Some(*kind),
2883                _ => None,
2884            })
2885            .context("expected a stream error delta")?;
2886
2887        assert_eq!(
2888            kind,
2889            StreamErrorKind::RateLimited(Some(std::time::Duration::from_millis(1500)))
2890        );
2891        Ok(())
2892    }
2893
2894    #[tokio::test]
2895    async fn in_band_failed_event_keeps_the_usage_it_reported() -> anyhow::Result<()> {
2896        // The failed response reports the tokens it burned. They are billed, so
2897        // they must reach the accumulator — which only sees yielded deltas —
2898        // before the error that ends the stream.
2899        let body = "data: {\"type\":\"response.failed\",\"response\":{\"usage\":{\"input_tokens\":140,\"output_tokens\":20},\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"Please try again in 12s.\"}}}\n\n";
2900        let deltas = stream_deltas(body).await?;
2901
2902        let usage_at = deltas
2903            .iter()
2904            .position(|delta| matches!(delta, StreamDelta::Usage(_)))
2905            .context("the failed event's usage must not be dropped")?;
2906        let error_at = deltas
2907            .iter()
2908            .position(|delta| matches!(delta, StreamDelta::Error { .. }))
2909            .context("expected a stream error delta")?;
2910        assert!(
2911            usage_at < error_at,
2912            "usage must be emitted before the terminal error, got {deltas:?}"
2913        );
2914
2915        let StreamDelta::Usage(usage) = &deltas[usage_at] else {
2916            bail!("expected a usage delta");
2917        };
2918        assert_eq!(usage.input_tokens, 140);
2919        assert_eq!(usage.output_tokens, 20);
2920        Ok(())
2921    }
2922
2923    #[tokio::test]
2924    async fn in_band_non_rate_limit_error_keeps_its_previous_classification() -> anyhow::Result<()>
2925    {
2926        let body = "data: {\"type\":\"error\",\"code\":\"invalid_prompt\",\"message\":\"bad\"}\n\n";
2927        let deltas = stream_deltas(body).await?;
2928        let kind = deltas
2929            .iter()
2930            .find_map(|delta| match delta {
2931                StreamDelta::Error { kind, .. } => Some(*kind),
2932                _ => None,
2933            })
2934            .context("expected a stream error delta")?;
2935
2936        assert_eq!(kind, StreamErrorKind::InvalidRequest);
2937        assert!(!kind.is_recoverable());
2938        Ok(())
2939    }
2940
2941    #[tokio::test]
2942    async fn in_band_response_failed_context_window_rejection_is_fatal() -> anyhow::Result<()> {
2943        // `response.failed` defaults to ServerError, but a context-window
2944        // rejection is a fatal request-shape error: the worker's overflow
2945        // compaction must fire instead of retrying the oversized payload.
2946        let body = "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"code\":\"context_length_exceeded\",\"message\":\"Your input exceeds the context window of this model. Please adjust your input and try again.\"}}}\n\n";
2947        let deltas = stream_deltas(body).await?;
2948        let kind = deltas
2949            .iter()
2950            .find_map(|delta| match delta {
2951                StreamDelta::Error { kind, .. } => Some(*kind),
2952                _ => None,
2953            })
2954            .context("expected a stream error delta")?;
2955
2956        assert_eq!(kind, StreamErrorKind::InvalidRequest);
2957        assert!(!kind.is_recoverable());
2958        Ok(())
2959    }
2960
2961    #[tokio::test]
2962    async fn semantic_terminal_event_preserves_streamed_reasoning_and_usage() -> anyhow::Result<()>
2963    {
2964        let body = concat!(
2965            "data: {\"type\":\"response.reasoning_summary_text.delta\",\"output_index\":0,\"delta\":\"Checked.\"}\n\n",
2966            "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"reasoning\",\"id\":\"rs_1\",\"encrypted_content\":\"cipher\",\"summary\":[]}}\n\n",
2967            "data: {\"type\":\"response.output_text.delta\",\"output_index\":1,\"delta\":\"Answer\"}\n\n",
2968            "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":12,\"output_tokens\":3,\"input_tokens_details\":{\"cached_tokens\":4,\"cache_write_tokens\":2}}}}\n\n",
2969            "data: [DONE]\n\n",
2970        );
2971        let deltas = stream_deltas(body).await?;
2972        assert!(deltas.iter().any(|delta| matches!(
2973            delta,
2974            StreamDelta::OpaqueReasoning { provider, data, block_index }
2975                if provider == "openai-responses"
2976                    && data["encrypted_content"] == "cipher"
2977                    && *block_index == 0
2978        )));
2979        assert!(deltas.iter().any(|delta| matches!(
2980            delta,
2981            StreamDelta::ThinkingDelta { delta, block_index }
2982                if delta == "Checked." && *block_index == 1
2983        )));
2984        assert!(deltas.iter().any(|delta| matches!(
2985            delta,
2986            StreamDelta::TextDelta { delta, block_index }
2987                if delta == "Answer" && *block_index == 2
2988        )));
2989        assert!(deltas.iter().any(|delta| matches!(
2990            delta,
2991            StreamDelta::Usage(usage)
2992                if usage.cached_input_tokens == 4 && usage.cache_creation_input_tokens == 2
2993        )));
2994        assert!(matches!(
2995            deltas.last(),
2996            Some(StreamDelta::Done {
2997                stop_reason: Some(StopReason::EndTurn),
2998                ..
2999            })
3000        ));
3001        Ok(())
3002    }
3003
3004    #[tokio::test]
3005    async fn non_tool_terminals_suppress_streamed_partial_tool_calls() -> anyhow::Result<()> {
3006        let incomplete = concat!(
3007            "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"id\":\"fc_1\",\"call_id\":\"call_1\",\"name\":\"lookup\"}}\n\n",
3008            "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"item_id\":\"fc_1\",\"delta\":\"{\"}\n\n",
3009            "data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n",
3010        );
3011        let incomplete_deltas = stream_deltas(incomplete).await?;
3012        assert!(matches!(
3013            incomplete_deltas.last(),
3014            Some(StreamDelta::Done {
3015                stop_reason: Some(StopReason::MaxTokens),
3016                ..
3017            })
3018        ));
3019        assert!(!incomplete_deltas.iter().any(|delta| matches!(
3020            delta,
3021            StreamDelta::ToolUseStart { .. } | StreamDelta::ToolInputDelta { .. }
3022        )));
3023
3024        let refusal = concat!(
3025            "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"id\":\"fc_1\",\"call_id\":\"call_1\",\"name\":\"lookup\"}}\n\n",
3026            "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"item_id\":\"fc_1\",\"delta\":\"{}\"}\n\n",
3027            "data: {\"type\":\"response.refusal.delta\",\"output_index\":1,\"delta\":\"Cannot comply.\"}\n\n",
3028            "data: {\"type\":\"response.completed\",\"response\":{}}\n\n",
3029        );
3030        let refusal_deltas = stream_deltas(refusal).await?;
3031        assert!(matches!(
3032            refusal_deltas.last(),
3033            Some(StreamDelta::Done {
3034                stop_reason: Some(StopReason::Refusal),
3035                ..
3036            })
3037        ));
3038        assert!(!refusal_deltas.iter().any(|delta| matches!(
3039            delta,
3040            StreamDelta::ToolUseStart { .. } | StreamDelta::ToolInputDelta { .. }
3041        )));
3042        Ok(())
3043    }
3044
3045    #[tokio::test]
3046    async fn done_sentinel_without_semantic_terminal_is_a_recoverable_error() -> anyhow::Result<()>
3047    {
3048        let body = concat!(
3049            "data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"delta\":\"partial\"}\n\n",
3050            "data: [DONE]\n\n",
3051        );
3052        let deltas = stream_deltas(body).await?;
3053        assert!(matches!(
3054            deltas.last(),
3055            Some(StreamDelta::Error {
3056                kind: StreamErrorKind::ServerError,
3057                ..
3058            })
3059        ));
3060        assert!(
3061            !deltas
3062                .iter()
3063                .any(|delta| matches!(delta, StreamDelta::Done { .. }))
3064        );
3065        Ok(())
3066    }
3067
3068    #[tokio::test]
3069    async fn streamed_refusal_is_visible_and_has_refusal_stop_reason() -> anyhow::Result<()> {
3070        let body = concat!(
3071            "data: {\"type\":\"response.refusal.delta\",\"output_index\":0,\"delta\":\"Cannot comply.\"}\n\n",
3072            "data: {\"type\":\"response.completed\",\"response\":{}}\n\n",
3073        );
3074        let deltas = stream_deltas(body).await?;
3075        assert!(deltas.iter().any(|delta| matches!(
3076            delta,
3077            StreamDelta::TextDelta { delta, .. } if delta == "Cannot comply."
3078        )));
3079        assert!(matches!(
3080            deltas.last(),
3081            Some(StreamDelta::Done {
3082                stop_reason: Some(StopReason::Refusal),
3083                ..
3084            })
3085        ));
3086        Ok(())
3087    }
3088}