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