Skip to main content

agent_sdk_providers/impls/
openai_responses.rs

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