Skip to main content

agent_sdk_providers/impls/
openai_responses.rs

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