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