Skip to main content

agent_sdk_providers/impls/
openai.rs

1//! `OpenAI` API provider implementation.
2//!
3//! This module provides an implementation of `LlmProvider` for the `OpenAI`
4//! Chat Completions API. It also supports `OpenAI`-compatible APIs (Ollama, vLLM, etc.)
5//! via the `with_base_url` constructor.
6//!
7//! # Transparent Responses-API reroute
8//!
9//! Some requests cannot be served by Chat Completions and are transparently
10//! rerouted to the `OpenAI` Responses API
11//! ([`OpenAIResponsesProvider`]). The reroute (`should_use_responses_api`) fires
12//! when:
13//!
14//! - the model only exists on the Responses surface (e.g. `gpt-5.3-codex`), or
15//! - GPT-5.6 is used against the official `OpenAI` API with automatic routing, or
16//! - the configured exact reasoning controls require the Responses API, or
17//! - the request carries attachments (images / documents), or
18//! - the request is *agentic* (has tools or tool-use/tool-result blocks) against
19//!   the official `api.openai.com` base URL.
20//!
21//! The reroute forwards the provider's pooled HTTP client and `extra_headers`
22//! (the BYOK / gateway auth mechanism) so a rerouted request keeps connection
23//! reuse and authenticates identically to a non-rerouted one.
24
25use crate::attachments::request_has_attachments;
26use crate::model_features::{ModelApiSurface, get_model_features};
27use crate::provider::LlmProvider;
28use crate::streaming::{SseLineBuffer, StreamBox, StreamDelta, StreamErrorKind};
29use agent_sdk_foundation::llm::{
30    ChatOutcome, ChatRequest, ChatResponse, Content, ContentBlock, StopReason, ThinkingConfig,
31    ToolChoice, Usage,
32};
33use anyhow::Result;
34use async_trait::async_trait;
35use futures::StreamExt;
36use reqwest::StatusCode;
37use serde::de::Error as _;
38use serde::ser::SerializeStruct as _;
39use serde::{Deserialize, Serialize};
40use std::collections::HashMap;
41
42use super::openai_reasoning::{
43    OpenAIAllowedToolsMode, OpenAIApiSurface, OpenAIPromptCacheMode, OpenAIPromptCacheTtl,
44    OpenAIReasoningConfig, OpenAIReasoningEffort, OpenAITextVerbosity, OpenAIToolChoice,
45    is_gpt56_model, legacy_reasoning_effort, validate_reasoning_config, validate_tool_choice,
46};
47use super::openai_responses::OpenAIResponsesProvider;
48use super::openai_schema::normalize_strict_schema;
49
50const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
51const OPENAI_RESPONSES_REASONING_PROVIDER: &str = "openai-responses";
52
53/// Build an HTTP client with connect/keepalive timeouts matching the sibling
54/// providers (`anthropic`, `vertex`). A bare `reqwest::Client::new()` has no
55/// connect timeout, so a black-holed connect would wedge `chat`/`chat_stream`
56/// forever.
57fn build_http_client() -> reqwest::Client {
58    reqwest::Client::builder()
59        .connect_timeout(std::time::Duration::from_secs(30))
60        .tcp_keepalive(std::time::Duration::from_secs(30))
61        .build()
62        .unwrap_or_default()
63}
64
65/// Check if a model requires the Responses API instead of Chat Completions.
66fn requires_responses_api(model: &str) -> bool {
67    model == MODEL_GPT52_CODEX
68        || get_model_features(model).is_some_and(|features| {
69            features.api_surfaces.contains(&ModelApiSurface::Responses)
70                && !features
71                    .api_surfaces
72                    .contains(&ModelApiSurface::ChatCompletions)
73        })
74}
75
76fn is_official_openai_base_url(base_url: &str) -> bool {
77    url::Url::parse(base_url).is_ok_and(|url| url.host_str() == Some("api.openai.com"))
78}
79
80fn chat_store(base_url: &str, config: Option<&OpenAIReasoningConfig>) -> Option<bool> {
81    config
82        .and_then(OpenAIReasoningConfig::store)
83        .or_else(|| is_official_openai_base_url(base_url).then_some(false))
84}
85
86fn chat_prompt_cache_key<'a>(base_url: &str, session_id: Option<&'a str>) -> Option<&'a str> {
87    is_official_openai_base_url(base_url)
88        .then_some(session_id)
89        .flatten()
90}
91
92fn request_is_agentic(request: &ChatRequest) -> bool {
93    request
94        .tools
95        .as_ref()
96        .is_some_and(|tools| !tools.is_empty()) || request.messages.iter().any(|message| {
97        matches!(
98            &message.content,
99            Content::Blocks(blocks)
100                if blocks.iter().any(|block| {
101                    matches!(block, ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. })
102                })
103        )
104    })
105}
106
107fn request_has_openai_responses_history(request: &ChatRequest) -> bool {
108    request.messages.iter().any(|message| {
109        matches!(
110            &message.content,
111            Content::Blocks(blocks)
112                if blocks.iter().any(|block| {
113                    matches!(
114                        block,
115                        ContentBlock::OpaqueReasoning { provider, .. }
116                            if provider == OPENAI_RESPONSES_REASONING_PROVIDER
117                    )
118                })
119        )
120    })
121}
122
123fn should_use_responses_api(
124    base_url: &str,
125    model: &str,
126    request: &ChatRequest,
127    reasoning: Option<&OpenAIReasoningConfig>,
128) -> bool {
129    let surface = reasoning.map_or(OpenAIApiSurface::Auto, OpenAIReasoningConfig::api_surface);
130    let explicitly_requests_responses = matches!(surface, OpenAIApiSurface::Responses);
131    let explicitly_requests_chat = matches!(surface, OpenAIApiSurface::ChatCompletions);
132    let response_only_reasoning = !explicitly_requests_chat
133        && reasoning.is_some_and(|config| {
134            matches!(config.api_surface(), OpenAIApiSurface::Responses)
135                || config.mode().is_some()
136                || config.context().is_some()
137                || config.summary().is_some()
138        });
139    let response_history_route =
140        !explicitly_requests_chat && request_has_openai_responses_history(request);
141    let official_auto_route = is_official_openai_base_url(base_url)
142        && !explicitly_requests_chat
143        && (is_gpt56_model(model) || request_is_agentic(request));
144
145    let attachment_route = !explicitly_requests_chat
146        && request_has_attachments(request)
147        && (is_official_openai_base_url(base_url) || explicitly_requests_responses);
148
149    requires_responses_api(model)
150        || explicitly_requests_responses
151        || response_only_reasoning
152        || response_history_route
153        || attachment_route
154        || official_auto_route
155}
156
157// GPT-5.6 series
158pub const MODEL_GPT56: &str = "gpt-5.6";
159pub const MODEL_GPT56_SOL: &str = "gpt-5.6-sol";
160pub const MODEL_GPT56_TERRA: &str = "gpt-5.6-terra";
161pub const MODEL_GPT56_LUNA: &str = "gpt-5.6-luna";
162
163// GPT-5.4 series
164pub const MODEL_GPT54: &str = "gpt-5.4";
165
166// GPT-5.3 Codex series
167pub const MODEL_GPT53_CODEX: &str = "gpt-5.3-codex";
168
169// GPT-5.2 series
170pub const MODEL_GPT52_INSTANT: &str = "gpt-5.2-instant";
171pub const MODEL_GPT52_THINKING: &str = "gpt-5.2-thinking";
172pub const MODEL_GPT52_PRO: &str = "gpt-5.2-pro";
173pub const MODEL_GPT52_CODEX: &str = "gpt-5.2-codex";
174
175// GPT-5 series (400k context)
176pub const MODEL_GPT5: &str = "gpt-5";
177pub const MODEL_GPT5_MINI: &str = "gpt-5-mini";
178pub const MODEL_GPT5_NANO: &str = "gpt-5-nano";
179
180// o-series reasoning models
181pub const MODEL_O3: &str = "o3";
182pub const MODEL_O3_MINI: &str = "o3-mini";
183pub const MODEL_O4_MINI: &str = "o4-mini";
184pub const MODEL_O1: &str = "o1";
185pub const MODEL_O1_MINI: &str = "o1-mini";
186
187// GPT-4.1 series (improved instruction following, 1M context)
188pub const MODEL_GPT41: &str = "gpt-4.1";
189pub const MODEL_GPT41_MINI: &str = "gpt-4.1-mini";
190pub const MODEL_GPT41_NANO: &str = "gpt-4.1-nano";
191
192// GPT-4o series
193pub const MODEL_GPT4O: &str = "gpt-4o";
194pub const MODEL_GPT4O_MINI: &str = "gpt-4o-mini";
195
196// OpenAI-compatible vendor defaults
197pub const BASE_URL_KIMI: &str = "https://api.moonshot.ai/v1";
198pub const BASE_URL_ZAI: &str = "https://api.z.ai/api/paas/v4";
199pub const BASE_URL_MINIMAX: &str = "https://api.minimax.io/v1";
200pub const MODEL_KIMI_K2_5: &str = "kimi-k2.5";
201pub const MODEL_KIMI_K2_THINKING: &str = "kimi-k2-thinking";
202pub const MODEL_ZAI_GLM5: &str = "glm-5";
203pub const MODEL_MINIMAX_M2_5: &str = "MiniMax-M2.5";
204
205/// `OpenAI` LLM provider using the Chat Completions API.
206///
207/// Also supports `OpenAI`-compatible APIs (Ollama, vLLM, Azure `OpenAI`, etc.)
208/// via the `with_base_url` constructor.
209#[derive(Clone)]
210pub struct OpenAIProvider {
211    client: reqwest::Client,
212    api_key: String,
213    model: String,
214    base_url: String,
215    thinking: Option<ThinkingConfig>,
216    reasoning: Option<OpenAIReasoningConfig>,
217    /// Extra headers applied to every request (e.g. for gateway authentication).
218    extra_headers: Vec<(String, String)>,
219}
220
221impl OpenAIProvider {
222    /// The conventional environment variable holding the `OpenAI` API key.
223    pub const API_KEY_ENV: &'static str = "OPENAI_API_KEY";
224
225    /// Create a new `OpenAI` provider with the specified API key and model.
226    #[must_use]
227    pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
228        Self {
229            client: build_http_client(),
230            api_key: api_key.into(),
231            model: model.into(),
232            base_url: DEFAULT_BASE_URL.to_owned(),
233            thinking: None,
234            reasoning: None,
235            extra_headers: Vec::new(),
236        }
237    }
238
239    /// Create a provider using GPT-5, reading the API key from the
240    /// conventional [`OPENAI_API_KEY`](Self::API_KEY_ENV) environment variable.
241    ///
242    /// # Panics
243    ///
244    /// Panics if `OPENAI_API_KEY` is not set. Prefer
245    /// [`try_from_env`](Self::try_from_env) outside of examples/tests.
246    #[must_use]
247    pub fn from_env() -> Self {
248        Self::try_from_env().unwrap_or_else(|e| panic!("{e}"))
249    }
250
251    /// Create a provider using GPT-5, reading the API key from the
252    /// conventional [`OPENAI_API_KEY`](Self::API_KEY_ENV) environment variable.
253    ///
254    /// # Errors
255    ///
256    /// Returns an error if `OPENAI_API_KEY` is unset or not valid UTF-8.
257    pub fn try_from_env() -> Result<Self> {
258        let api_key = std::env::var(Self::API_KEY_ENV).map_err(|_| {
259            anyhow::anyhow!("environment variable `{}` is not set", Self::API_KEY_ENV)
260        })?;
261        Ok(Self::gpt5(api_key))
262    }
263
264    /// Create a new provider with a custom base URL for OpenAI-compatible APIs.
265    #[must_use]
266    pub fn with_base_url(
267        api_key: impl Into<String>,
268        model: impl Into<String>,
269        base_url: impl Into<String>,
270    ) -> Self {
271        Self {
272            client: build_http_client(),
273            api_key: api_key.into(),
274            model: model.into(),
275            base_url: base_url.into(),
276            thinking: None,
277            reasoning: None,
278            extra_headers: Vec::new(),
279        }
280    }
281
282    /// Create a provider using Moonshot KIMI via OpenAI-compatible Chat Completions.
283    #[must_use]
284    pub fn kimi(api_key: String, model: String) -> Self {
285        Self::with_base_url(api_key, model, BASE_URL_KIMI.to_owned())
286    }
287
288    /// Create a provider using KIMI K2.5 (default KIMI model).
289    #[must_use]
290    pub fn kimi_k2_5(api_key: String) -> Self {
291        Self::kimi(api_key, MODEL_KIMI_K2_5.to_owned())
292    }
293
294    /// Create a provider using KIMI K2 Thinking.
295    #[must_use]
296    pub fn kimi_k2_thinking(api_key: String) -> Self {
297        Self::kimi(api_key, MODEL_KIMI_K2_THINKING.to_owned())
298    }
299
300    /// Create a provider using z.ai via OpenAI-compatible Chat Completions.
301    #[must_use]
302    pub fn zai(api_key: String, model: String) -> Self {
303        Self::with_base_url(api_key, model, BASE_URL_ZAI.to_owned())
304    }
305
306    /// Create a provider using z.ai GLM-5 (default z.ai agentic reasoning model).
307    #[must_use]
308    pub fn zai_glm5(api_key: String) -> Self {
309        Self::zai(api_key, MODEL_ZAI_GLM5.to_owned())
310    }
311
312    /// Create a provider using `MiniMax` via OpenAI-compatible Chat Completions.
313    #[must_use]
314    pub fn minimax(api_key: String, model: String) -> Self {
315        Self::with_base_url(api_key, model, BASE_URL_MINIMAX.to_owned())
316    }
317
318    /// Create a provider using `MiniMax` M2.5 (default `MiniMax` model).
319    #[must_use]
320    pub fn minimax_m2_5(api_key: String) -> Self {
321        Self::minimax(api_key, MODEL_MINIMAX_M2_5.to_owned())
322    }
323
324    /// Create a provider using the GPT-5.6 alias, which routes to GPT-5.6 Sol.
325    #[must_use]
326    pub fn gpt56(api_key: String) -> Self {
327        Self::new(api_key, MODEL_GPT56.to_owned())
328    }
329
330    /// Create a provider using GPT-5.6 Sol (frontier reasoning and coding).
331    #[must_use]
332    pub fn gpt56_sol(api_key: String) -> Self {
333        Self::new(api_key, MODEL_GPT56_SOL.to_owned())
334    }
335
336    /// Create a provider using GPT-5.6 Terra (balanced intelligence and cost).
337    #[must_use]
338    pub fn gpt56_terra(api_key: String) -> Self {
339        Self::new(api_key, MODEL_GPT56_TERRA.to_owned())
340    }
341
342    /// Create a provider using GPT-5.6 Luna (cost-sensitive, high-volume work).
343    #[must_use]
344    pub fn gpt56_luna(api_key: String) -> Self {
345        Self::new(api_key, MODEL_GPT56_LUNA.to_owned())
346    }
347
348    /// Create a provider using GPT-5.2 Instant (speed-optimized for routine queries).
349    #[must_use]
350    pub fn gpt52_instant(api_key: String) -> Self {
351        Self::new(api_key, MODEL_GPT52_INSTANT.to_owned())
352    }
353
354    /// Create a provider using GPT-5.4 (frontier reasoning with 1.05M context).
355    #[must_use]
356    pub fn gpt54(api_key: String) -> Self {
357        Self::new(api_key, MODEL_GPT54.to_owned())
358    }
359
360    /// Create a provider using GPT-5.3 Codex (latest codex model).
361    #[must_use]
362    pub fn gpt53_codex(api_key: String) -> Self {
363        Self::new(api_key, MODEL_GPT53_CODEX.to_owned())
364    }
365
366    /// Create a provider using GPT-5.2 Thinking (complex reasoning, coding, analysis).
367    #[must_use]
368    pub fn gpt52_thinking(api_key: String) -> Self {
369        Self::new(api_key, MODEL_GPT52_THINKING.to_owned())
370    }
371
372    /// Create a provider using GPT-5.2 Pro (maximum accuracy for difficult problems).
373    #[must_use]
374    pub fn gpt52_pro(api_key: String) -> Self {
375        Self::new(api_key, MODEL_GPT52_PRO.to_owned())
376    }
377
378    /// Create a provider using the latest Codex model.
379    #[must_use]
380    pub fn codex(api_key: String) -> Self {
381        Self::gpt53_codex(api_key)
382    }
383
384    /// Create a provider using GPT-5 (400k context, coding and reasoning).
385    #[must_use]
386    pub fn gpt5(api_key: String) -> Self {
387        Self::new(api_key, MODEL_GPT5.to_owned())
388    }
389
390    /// Create a provider using GPT-5-mini (faster, cost-efficient GPT-5).
391    #[must_use]
392    pub fn gpt5_mini(api_key: String) -> Self {
393        Self::new(api_key, MODEL_GPT5_MINI.to_owned())
394    }
395
396    /// Create a provider using GPT-5-nano (fastest, cheapest GPT-5 variant).
397    #[must_use]
398    pub fn gpt5_nano(api_key: String) -> Self {
399        Self::new(api_key, MODEL_GPT5_NANO.to_owned())
400    }
401
402    /// Create a provider using o3 (most intelligent reasoning model).
403    #[must_use]
404    pub fn o3(api_key: String) -> Self {
405        Self::new(api_key, MODEL_O3.to_owned())
406    }
407
408    /// Create a provider using o3-mini (smaller o3 variant).
409    #[must_use]
410    pub fn o3_mini(api_key: String) -> Self {
411        Self::new(api_key, MODEL_O3_MINI.to_owned())
412    }
413
414    /// Create a provider using o4-mini (fast, cost-efficient reasoning).
415    #[must_use]
416    pub fn o4_mini(api_key: String) -> Self {
417        Self::new(api_key, MODEL_O4_MINI.to_owned())
418    }
419
420    /// Create a provider using o1 (reasoning model).
421    #[must_use]
422    pub fn o1(api_key: String) -> Self {
423        Self::new(api_key, MODEL_O1.to_owned())
424    }
425
426    /// Create a provider using o1-mini (fast reasoning model).
427    #[must_use]
428    pub fn o1_mini(api_key: String) -> Self {
429        Self::new(api_key, MODEL_O1_MINI.to_owned())
430    }
431
432    /// Create a provider using GPT-4.1 (improved instruction following, 1M context).
433    #[must_use]
434    pub fn gpt41(api_key: String) -> Self {
435        Self::new(api_key, MODEL_GPT41.to_owned())
436    }
437
438    /// Create a provider using GPT-4.1-mini (smaller, faster GPT-4.1).
439    #[must_use]
440    pub fn gpt41_mini(api_key: String) -> Self {
441        Self::new(api_key, MODEL_GPT41_MINI.to_owned())
442    }
443
444    /// Create a provider using GPT-4o.
445    #[must_use]
446    pub fn gpt4o(api_key: String) -> Self {
447        Self::new(api_key, MODEL_GPT4O.to_owned())
448    }
449
450    /// Create a provider using GPT-4o-mini (fast and cost-effective).
451    #[must_use]
452    pub fn gpt4o_mini(api_key: String) -> Self {
453        Self::new(api_key, MODEL_GPT4O_MINI.to_owned())
454    }
455
456    /// Set the provider-owned thinking configuration for this model.
457    #[must_use]
458    pub fn with_thinking(mut self, thinking: ThinkingConfig) -> Self {
459        self.thinking = Some(thinking);
460        self.reasoning = None;
461        self
462    }
463
464    /// Set exact `OpenAI` reasoning and related response controls.
465    ///
466    /// This is the lossless path for GPT-5.6 `none` / `xhigh` / `max`, pro
467    /// mode, persisted reasoning context, summaries, and API-surface selection.
468    /// Calling this after [`with_thinking`](Self::with_thinking) replaces the
469    /// legacy provider-owned thinking configuration.
470    #[must_use]
471    pub fn with_reasoning(mut self, reasoning: OpenAIReasoningConfig) -> Self {
472        self.reasoning = Some(reasoning);
473        self.thinking = None;
474        self
475    }
476
477    /// Add extra HTTP headers applied to every request.
478    #[must_use]
479    pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
480        self.extra_headers = headers;
481        self
482    }
483
484    /// Apply auth + extra headers. Skips `Authorization` when `api_key` is
485    /// empty (BYOK gateway mode — auth handled via `extra_headers`).
486    fn apply_headers(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
487        let builder = if self.api_key.is_empty() {
488            builder
489        } else {
490            builder.header("Authorization", format!("Bearer {}", self.api_key))
491        };
492        self.extra_headers
493            .iter()
494            .fold(builder, |b, (k, v)| b.header(k.as_str(), v.as_str()))
495    }
496
497    fn effective_max_tokens(&self, request: &ChatRequest) -> u32 {
498        if request.max_tokens_explicit {
499            request.max_tokens
500        } else {
501            self.default_max_tokens()
502        }
503    }
504
505    fn resolve_openai_reasoning(
506        &self,
507        request_thinking: Option<&ThinkingConfig>,
508    ) -> Result<Option<OpenAIReasoningConfig>> {
509        let legacy = if request_thinking.is_some() || self.reasoning.is_none() {
510            self.resolve_thinking_config(request_thinking)?
511        } else {
512            None
513        };
514
515        let config = match (self.reasoning.clone(), legacy.as_ref()) {
516            (Some(config), Some(thinking)) => {
517                Some(config.with_optional_effort(legacy_reasoning_effort(thinking)))
518            }
519            (Some(config), None) => Some(config),
520            (None, Some(thinking)) => Some(
521                OpenAIReasoningConfig::new()
522                    .with_optional_effort(legacy_reasoning_effort(thinking)),
523            ),
524            (None, None) => None,
525        };
526
527        if let Some(config) = &config {
528            validate_reasoning_config(&self.model, config)?;
529        }
530        Ok(config)
531    }
532
533    fn validate_chat_attachments(&self, request: &ChatRequest) -> Result<()> {
534        if request_has_attachments(request) {
535            anyhow::bail!(
536                "OpenAI Chat Completions request for model={} contains image or document attachments that this provider cannot serialize; use the Responses API",
537                self.model
538            );
539        }
540        Ok(())
541    }
542
543    fn validate_chat_opaque_reasoning(request: &ChatRequest) -> Result<()> {
544        if request_has_openai_responses_history(request) {
545            anyhow::bail!(
546                "OpenAI Responses reasoning history cannot be serialized through Chat Completions; use the Responses API"
547            );
548        }
549        Ok(())
550    }
551
552    fn validate_requested_api_surface(&self) -> Result<()> {
553        if self
554            .reasoning
555            .as_ref()
556            .is_some_and(|config| matches!(config.api_surface(), OpenAIApiSurface::ChatCompletions))
557            && requires_responses_api(&self.model)
558        {
559            anyhow::bail!(
560                "model={} is only available through the OpenAI Responses API",
561                self.model
562            );
563        }
564        Ok(())
565    }
566
567    fn validate_chat_reasoning_controls(config: Option<&OpenAIReasoningConfig>) -> Result<()> {
568        let Some(config) = config else {
569            return Ok(());
570        };
571        if config.mode().is_some() || config.context().is_some() || config.summary().is_some() {
572            anyhow::bail!(
573                "OpenAI reasoning mode, context, and summary controls require the Responses API"
574            );
575        }
576        Ok(())
577    }
578
579    fn validate_chat_response_format(
580        &self,
581        response_format: Option<&agent_sdk_foundation::llm::ResponseFormat>,
582    ) -> Result<()> {
583        if !is_official_openai_base_url(&self.base_url) {
584            return Ok(());
585        }
586        let Some(response_format) = response_format.filter(|format| format.strict) else {
587            return Ok(());
588        };
589        let mut schema = response_format.schema.clone();
590        if !normalize_strict_schema(&mut schema) {
591            anyhow::bail!(
592                "OpenAI strict structured output `{}` contains a free-form object schema",
593                response_format.name
594            );
595        }
596        Ok(())
597    }
598
599    fn resolve_chat_prompt_cache_options(
600        &self,
601        request: &ChatRequest,
602        config: Option<&OpenAIReasoningConfig>,
603    ) -> Result<ChatPromptCachePlan> {
604        let exact_options = ApiPromptCacheOptions::from_config(config);
605        if !is_gpt56_model(&self.model) && request.cache.is_some() {
606            return Ok(ChatPromptCachePlan {
607                options: exact_options,
608                explicit_breakpoints: 0,
609            });
610        }
611        let Some(cache) = request.cache.as_ref() else {
612            return Ok(ChatPromptCachePlan {
613                options: exact_options,
614                explicit_breakpoints: 0,
615            });
616        };
617
618        if let Some(ttl) = cache.ttl {
619            anyhow::bail!(
620                "OpenAI GPT-5.6 prompt caching supports only a 30m TTL; shared cache TTL {} cannot be represented for model={}",
621                ttl.as_wire_str(),
622                self.model
623            );
624        }
625
626        if !cache.enabled {
627            return Ok(ChatPromptCachePlan {
628                options: Some(ApiPromptCacheOptions::new(OpenAIPromptCacheMode::Explicit)),
629                explicit_breakpoints: 0,
630            });
631        }
632
633        if let Some(max_breakpoints) = cache.max_breakpoints {
634            return Ok(ChatPromptCachePlan {
635                options: Some(ApiPromptCacheOptions {
636                    mode: Some(OpenAIPromptCacheMode::Explicit),
637                    ttl: config.and_then(OpenAIReasoningConfig::prompt_cache_ttl),
638                }),
639                explicit_breakpoints: usize::from(max_breakpoints.min(4)),
640            });
641        }
642
643        Ok(ChatPromptCachePlan {
644            options: exact_options,
645            explicit_breakpoints: 0,
646        })
647    }
648
649    fn resolve_chat_tool_choice(
650        request_choice: Option<&ToolChoice>,
651        config: Option<&OpenAIReasoningConfig>,
652        tools: Option<&[agent_sdk_foundation::llm::Tool]>,
653    ) -> Result<Option<ApiToolChoice>> {
654        if let Some(choice) = request_choice {
655            if let ToolChoice::Tool(name) = choice
656                && !tools
657                    .unwrap_or_default()
658                    .iter()
659                    .any(|tool| tool.name == *name)
660            {
661                anyhow::bail!("OpenAI tool_choice names unknown function `{name}`");
662            }
663            return Ok(Some(ApiToolChoice::from_tool_choice(choice)));
664        }
665
666        validate_tool_choice(config, tools)?;
667        Ok(config
668            .and_then(OpenAIReasoningConfig::tool_choice)
669            .map(ApiToolChoice::from_openai_tool_choice))
670    }
671
672    async fn send_chat_request(
673        &self,
674        api_request: &ApiChatRequest<'_>,
675    ) -> Result<(StatusCode, Vec<u8>, Option<std::time::Duration>)> {
676        let builder = self
677            .client
678            .post(format!("{}/chat/completions", self.base_url))
679            .header("Content-Type", "application/json");
680        let response = self
681            .apply_headers(builder)
682            .json(api_request)
683            .send()
684            .await
685            .map_err(|error| anyhow::anyhow!("request failed: {error}"))?;
686        let status = response.status();
687        let retry_after = (status == StatusCode::TOO_MANY_REQUESTS)
688            .then(|| crate::http::retry_after_from_headers(response.headers()))
689            .flatten();
690        let bytes = response
691            .bytes()
692            .await
693            .map_err(|error| anyhow::anyhow!("failed to read response body: {error}"))?
694            .to_vec();
695        Ok((status, bytes, retry_after))
696    }
697
698    /// Build the `OpenAIResponsesProvider` used for the transparent Responses-API
699    /// reroute, forwarding this provider's pooled client, thinking config, and
700    /// extra headers so the rerouted request reuses connections and authenticates
701    /// identically (critical for BYOK / gateway setups with an empty `api_key`).
702    fn responses_reroute(&self) -> OpenAIResponsesProvider {
703        let mut provider = OpenAIResponsesProvider::with_base_url(
704            self.api_key.clone(),
705            self.model.clone(),
706            self.base_url.clone(),
707        )
708        .with_client(self.client.clone())
709        .with_extra_headers(self.extra_headers.clone());
710        if let Some(thinking) = self.thinking.clone() {
711            provider = provider.with_thinking(thinking);
712        }
713        if let Some(reasoning) = self.reasoning.clone() {
714            provider = provider.with_reasoning(reasoning);
715        }
716        provider
717    }
718}
719
720#[async_trait]
721impl LlmProvider for OpenAIProvider {
722    async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
723        if let Err(error) = self.validate_requested_api_surface() {
724            return Ok(ChatOutcome::InvalidRequest(error.to_string()));
725        }
726        // Route official OpenAI agentic flows to the Responses API, preserving
727        // the pooled client and extra_headers (BYOK / gateway auth).
728        if should_use_responses_api(
729            &self.base_url,
730            &self.model,
731            &request,
732            self.reasoning.as_ref(),
733        ) {
734            return self.responses_reroute().chat(request).await;
735        }
736
737        let reasoning_config = match self.resolve_openai_reasoning(request.thinking.as_ref()) {
738            Ok(reasoning) => reasoning,
739            Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
740        };
741        if let Err(error) = Self::validate_chat_reasoning_controls(reasoning_config.as_ref()) {
742            return Ok(ChatOutcome::InvalidRequest(error.to_string()));
743        }
744        if let Err(error) = Self::validate_chat_opaque_reasoning(&request) {
745            return Ok(ChatOutcome::InvalidRequest(error.to_string()));
746        }
747        if let Err(error) = self.validate_chat_attachments(&request) {
748            return Ok(ChatOutcome::InvalidRequest(error.to_string()));
749        }
750        if let Err(error) = self.validate_chat_response_format(request.response_format.as_ref()) {
751            return Ok(ChatOutcome::InvalidRequest(error.to_string()));
752        }
753        let prompt_cache =
754            match self.resolve_chat_prompt_cache_options(&request, reasoning_config.as_ref()) {
755                Ok(plan) => plan,
756                Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
757            };
758        let tool_choice = match Self::resolve_chat_tool_choice(
759            request.tool_choice.as_ref(),
760            reasoning_config.as_ref(),
761            request.tools.as_deref(),
762        ) {
763            Ok(choice) => choice,
764            Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
765        };
766        let official_openai = is_official_openai_base_url(&self.base_url);
767        let reasoning = build_chat_api_reasoning(
768            reasoning_config.as_ref(),
769            official_openai || self.reasoning.is_some(),
770        );
771        let verbosity = reasoning_config
772            .as_ref()
773            .and_then(OpenAIReasoningConfig::verbosity);
774        let store = chat_store(&self.base_url, reasoning_config.as_ref());
775        let parallel_tool_calls = reasoning_config
776            .as_ref()
777            .and_then(OpenAIReasoningConfig::parallel_tool_calls);
778        let safety_identifier = reasoning_config
779            .as_ref()
780            .and_then(OpenAIReasoningConfig::safety_identifier);
781        let prompt_cache_key = chat_prompt_cache_key(&self.base_url, request.session_id.as_deref());
782        let max_tokens = self.effective_max_tokens(&request);
783        let messages = build_api_messages_with_cache(&request, prompt_cache.explicit_breakpoints);
784        let tools: Option<Vec<ApiTool>> = request.tools.map(|ts| {
785            ts.into_iter()
786                .map(|tool| convert_tool(tool, official_openai))
787                .collect()
788        });
789        let response_format = request
790            .response_format
791            .as_ref()
792            .map(|format| ApiResponseFormat::from_response_format(format, official_openai));
793
794        let include_max_tokens_alias = use_max_tokens_alias(&self.base_url);
795        let api_request = ApiChatRequest {
796            model: &self.model,
797            messages: &messages,
798            max_completion_tokens: Some(max_tokens),
799            max_tokens: include_max_tokens_alias.then_some(max_tokens),
800            tools: tools.as_deref(),
801            tool_choice,
802            reasoning,
803            response_format,
804            verbosity,
805            prompt_cache_options: prompt_cache.options,
806            store,
807            parallel_tool_calls,
808            safety_identifier,
809            prompt_cache_key,
810        };
811
812        log::debug!(
813            "OpenAI LLM request model={} max_tokens={}",
814            self.model,
815            max_tokens
816        );
817
818        let (status, bytes, retry_after) = self.send_chat_request(&api_request).await?;
819
820        log::debug!(
821            "OpenAI LLM response status={} body_len={}",
822            status,
823            bytes.len()
824        );
825
826        decode_chat_response(status, &bytes, retry_after)
827    }
828
829    #[allow(clippy::too_many_lines)]
830    fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
831        if let Err(error) = self.validate_requested_api_surface() {
832            return Box::pin(async_stream::stream! {
833                yield Ok(StreamDelta::Error {
834                    message: error.to_string(),
835                    kind: StreamErrorKind::InvalidRequest,
836                });
837            });
838        }
839        // Route official OpenAI agentic flows to the Responses API, preserving
840        // the pooled client and extra_headers (BYOK / gateway auth).
841        if should_use_responses_api(
842            &self.base_url,
843            &self.model,
844            &request,
845            self.reasoning.as_ref(),
846        ) {
847            let responses_provider = self.responses_reroute();
848            return Box::pin(async_stream::stream! {
849                let mut stream = std::pin::pin!(responses_provider.chat_stream(request));
850                while let Some(item) = futures::StreamExt::next(&mut stream).await {
851                    yield item;
852                }
853            });
854        }
855
856        Box::pin(async_stream::stream! {
857            let reasoning_config = match self.resolve_openai_reasoning(request.thinking.as_ref()) {
858                Ok(reasoning) => reasoning,
859                Err(error) => {
860                    yield Ok(StreamDelta::Error {
861                        message: error.to_string(),
862                        kind: StreamErrorKind::InvalidRequest,
863                    });
864                    return;
865                }
866            };
867            if let Err(error) = Self::validate_chat_reasoning_controls(reasoning_config.as_ref()) {
868                yield Ok(StreamDelta::Error {
869                    message: error.to_string(),
870                    kind: StreamErrorKind::InvalidRequest,
871                });
872                return;
873            }
874            if let Err(error) = Self::validate_chat_opaque_reasoning(&request) {
875                yield Ok(StreamDelta::Error {
876                    message: error.to_string(),
877                    kind: StreamErrorKind::InvalidRequest,
878                });
879                return;
880            }
881            if let Err(error) = self.validate_chat_attachments(&request) {
882                yield Ok(StreamDelta::Error {
883                    message: error.to_string(),
884                    kind: StreamErrorKind::InvalidRequest,
885                });
886                return;
887            }
888            if let Err(error) = self.validate_chat_response_format(request.response_format.as_ref()) {
889                yield Ok(StreamDelta::Error {
890                    message: error.to_string(),
891                    kind: StreamErrorKind::InvalidRequest,
892                });
893                return;
894            }
895            let prompt_cache = match self
896                .resolve_chat_prompt_cache_options(&request, reasoning_config.as_ref())
897            {
898                Ok(plan) => plan,
899                Err(error) => {
900                    yield Ok(StreamDelta::Error {
901                        message: error.to_string(),
902                        kind: StreamErrorKind::InvalidRequest,
903                    });
904                    return;
905                }
906            };
907            let tool_choice = match Self::resolve_chat_tool_choice(
908                request.tool_choice.as_ref(),
909                reasoning_config.as_ref(),
910                request.tools.as_deref(),
911            ) {
912                Ok(choice) => choice,
913                Err(error) => {
914                    yield Ok(StreamDelta::Error {
915                        message: error.to_string(),
916                        kind: StreamErrorKind::InvalidRequest,
917                    });
918                    return;
919                }
920            };
921            let official_openai = is_official_openai_base_url(&self.base_url);
922            let reasoning = build_chat_api_reasoning(
923                reasoning_config.as_ref(),
924                official_openai || self.reasoning.is_some(),
925            );
926            let verbosity = reasoning_config
927                .as_ref()
928                .and_then(OpenAIReasoningConfig::verbosity);
929            let store = chat_store(&self.base_url, reasoning_config.as_ref());
930            let parallel_tool_calls = reasoning_config
931                .as_ref()
932                .and_then(OpenAIReasoningConfig::parallel_tool_calls);
933            let safety_identifier = reasoning_config
934                .as_ref()
935                .and_then(OpenAIReasoningConfig::safety_identifier);
936            let prompt_cache_key =
937                chat_prompt_cache_key(&self.base_url, request.session_id.as_deref());
938            let max_tokens = self.effective_max_tokens(&request);
939            let messages =
940                build_api_messages_with_cache(&request, prompt_cache.explicit_breakpoints);
941            let tools: Option<Vec<ApiTool>> = request
942                .tools
943                .map(|ts| {
944                    ts.into_iter()
945                        .map(|tool| convert_tool(tool, official_openai))
946                        .collect()
947                });
948            let response_format = request
949                .response_format
950                .as_ref()
951                .map(|format| ApiResponseFormat::from_response_format(format, official_openai));
952
953            let include_max_tokens_alias = use_max_tokens_alias(&self.base_url);
954            let include_stream_usage = use_stream_usage_options(&self.base_url);
955            let include_openrouter_usage = use_openrouter_usage_options(&self.base_url);
956            let api_request = ApiChatRequestStreaming {
957                model: &self.model,
958                messages: &messages,
959                max_completion_tokens: Some(max_tokens),
960                max_tokens: include_max_tokens_alias.then_some(max_tokens),
961                tools: tools.as_deref(),
962                tool_choice,
963                reasoning,
964                response_format,
965                verbosity,
966                prompt_cache_options: prompt_cache.options,
967                store,
968                parallel_tool_calls,
969                safety_identifier,
970                prompt_cache_key,
971                stream_options: include_stream_usage.then_some(ApiStreamOptions {
972                    include_usage: true,
973                }),
974                usage: include_openrouter_usage
975                    .then_some(ApiOpenRouterUsageOptions { include: true }),
976                stream: true,
977            };
978
979            log::debug!("OpenAI streaming LLM request model={} max_tokens={}", self.model, max_tokens);
980
981            let stream_builder = self.client
982                .post(format!("{}/chat/completions", self.base_url))
983                .header("Content-Type", "application/json");
984            let Ok(response) = self
985                .apply_headers(stream_builder)
986                .json(&api_request)
987                .send()
988                .await
989            else {
990                yield Err(anyhow::anyhow!("request failed"));
991                return;
992            };
993
994            let status = response.status();
995
996            if !status.is_success() {
997                let body = response.text().await.unwrap_or_default();
998                let (kind, level) = if status == StatusCode::TOO_MANY_REQUESTS {
999                    (StreamErrorKind::RateLimited, "rate_limit")
1000                } else if status.is_server_error() {
1001                    (StreamErrorKind::ServerError, "server_error")
1002                } else {
1003                    (StreamErrorKind::InvalidRequest, "client_error")
1004                };
1005                log::warn!("OpenAI error status={status} body={body} kind={level}");
1006                yield Ok(StreamDelta::Error { message: body, kind });
1007                return;
1008            }
1009
1010            // Track tool call state across deltas
1011            let mut tool_calls: HashMap<usize, ToolCallAccumulator> = HashMap::new();
1012            let mut usage: Option<Usage> = None;
1013            // The stop reason from `finish_reason`. With stream_options.include_usage
1014            // (official OpenAI) the usage arrives in a SEPARATE trailing chunk
1015            // (choices: []) AFTER finish_reason and before [DONE], so we record the
1016            // stop reason and keep consuming until [DONE] / stream end rather than
1017            // returning early and dropping that usage chunk.
1018            let mut stop_reason: Option<StopReason> = None;
1019            let mut sse = SseLineBuffer::new();
1020            let mut stream = response.bytes_stream();
1021
1022            while let Some(chunk_result) = stream.next().await {
1023                let chunk = match chunk_result {
1024                    Ok(chunk) => chunk,
1025                    Err(error) => {
1026                        yield Err(anyhow::anyhow!("stream error: {error}"));
1027                        return;
1028                    }
1029                };
1030                sse.extend(&chunk);
1031
1032                while let Some(line) = sse.next_line() {
1033                    let line = line.trim();
1034                    if line.is_empty() { continue; }
1035                    let Some(data) = line.strip_prefix("data: ") else { continue; };
1036
1037                    let outcome = step_completion_stream(
1038                        data,
1039                        &mut tool_calls,
1040                        &mut usage,
1041                        &mut stop_reason,
1042                    );
1043                    for delta in outcome.immediate { yield Ok(delta); }
1044                    if let Some(terminal) = outcome.terminal {
1045                        for delta in terminal { yield Ok(delta); }
1046                        return;
1047                    }
1048                }
1049            }
1050
1051            // A successful Chat Completions stream terminates with [DONE]. An
1052            // EOF before that sentinel is transport truncation, even if a
1053            // finish_reason happened to arrive first; synthesizing Done would
1054            // let callers commit a partial response as a successful turn.
1055            yield Err(anyhow::anyhow!("OpenAI stream ended before [DONE] sentinel"));
1056        })
1057    }
1058
1059    async fn list_models(&self) -> Result<Vec<crate::provider::ModelInfo>> {
1060        let builder = self
1061            .client
1062            .get(format!("{}/models", self.base_url))
1063            .header("Content-Type", "application/json");
1064        let builder = self.apply_headers(builder);
1065        let body = crate::impls::model_listing::fetch_model_list_body(builder, "OpenAI").await?;
1066        parse_models_list(&body)
1067    }
1068
1069    fn model(&self) -> &str {
1070        &self.model
1071    }
1072
1073    fn provider(&self) -> &'static str {
1074        "openai"
1075    }
1076
1077    fn configured_thinking(&self) -> Option<&ThinkingConfig> {
1078        self.thinking.as_ref()
1079    }
1080}
1081
1082/// Parse the `OpenAI` `GET /models` response body into [`ModelInfo`] rows.
1083///
1084/// The Chat Completions list endpoint returns `{ "data": [{ "id", ... }] }`
1085/// and reports neither a display name nor token limits, so those fields stay
1086/// `None`. This shape is shared by the OpenAI-compatible vendor APIs.
1087fn parse_models_list(body: &str) -> Result<Vec<crate::provider::ModelInfo>> {
1088    #[derive(Deserialize)]
1089    struct ListResponse {
1090        #[serde(default)]
1091        data: Vec<ModelRow>,
1092    }
1093    #[derive(Deserialize)]
1094    struct ModelRow {
1095        id: String,
1096    }
1097    let parsed: ListResponse = serde_json::from_str(body)
1098        .map_err(|e| anyhow::anyhow!("failed to parse OpenAI models list: {e}"))?;
1099    Ok(parsed
1100        .data
1101        .into_iter()
1102        .map(|row| crate::provider::ModelInfo {
1103            id: row.id,
1104            display_name: None,
1105            context_window: None,
1106            max_output_tokens: None,
1107        })
1108        .collect())
1109}
1110
1111/// Apply a tool call update to the accumulator.
1112fn apply_tool_call_update(
1113    tool_calls: &mut std::collections::HashMap<usize, ToolCallAccumulator>,
1114    index: usize,
1115    id: Option<String>,
1116    name: Option<String>,
1117    arguments: Option<String>,
1118) {
1119    let entry = tool_calls
1120        .entry(index)
1121        .or_insert_with(|| ToolCallAccumulator {
1122            id: String::new(),
1123            name: String::new(),
1124            arguments: String::new(),
1125        });
1126    if let Some(id) = id {
1127        entry.id = id;
1128    }
1129    if let Some(name) = name {
1130        entry.name = name;
1131    }
1132    if let Some(args) = arguments {
1133        entry.arguments.push_str(&args);
1134    }
1135}
1136
1137/// Immediate + terminal deltas produced by feeding one SSE `data:` line to the
1138/// Chat Completions streaming state.
1139struct SseLineOutcome {
1140    /// Deltas to yield immediately (text / thinking).
1141    immediate: Vec<StreamDelta>,
1142    /// When `Some`, the stream finished ([DONE] received): yield these terminal
1143    /// deltas (tool calls + usage + Done) and stop.
1144    terminal: Option<Vec<StreamDelta>>,
1145}
1146
1147/// Feed one SSE `data:` payload to the streaming state, accumulating tool calls,
1148/// usage, and the stop reason.
1149///
1150/// Text/thinking deltas are returned for immediate emission. A `finish_reason`
1151/// only records the stop reason (it does NOT finalize) so a trailing usage-only
1152/// chunk that official `OpenAI` sends after `finish_reason` is still folded in;
1153/// finalization happens on the `[DONE]` sentinel.
1154fn step_completion_stream(
1155    data: &str,
1156    tool_calls: &mut HashMap<usize, ToolCallAccumulator>,
1157    usage: &mut Option<Usage>,
1158    stop_reason: &mut Option<StopReason>,
1159) -> SseLineOutcome {
1160    let mut immediate = Vec::new();
1161    for result in process_sse_data(data) {
1162        match result {
1163            SseProcessResult::TextDelta(c) => {
1164                immediate.push(StreamDelta::TextDelta {
1165                    delta: c,
1166                    block_index: 0,
1167                });
1168            }
1169            SseProcessResult::RefusalDelta(c) => {
1170                immediate.push(StreamDelta::TextDelta {
1171                    delta: c,
1172                    block_index: 0,
1173                });
1174                *stop_reason = Some(StopReason::Refusal);
1175            }
1176            SseProcessResult::ThinkingDelta(c) => {
1177                immediate.push(StreamDelta::ThinkingDelta {
1178                    delta: c,
1179                    block_index: 0,
1180                });
1181            }
1182            SseProcessResult::ToolCallUpdate {
1183                index,
1184                id,
1185                name,
1186                arguments,
1187            } => apply_tool_call_update(tool_calls, index, id, name, arguments),
1188            SseProcessResult::Usage(u) => *usage = Some(u),
1189            SseProcessResult::Done(sr) => {
1190                if !matches!(stop_reason, Some(StopReason::Refusal)) {
1191                    *stop_reason = Some(sr);
1192                }
1193            }
1194            SseProcessResult::Malformed(message) => {
1195                return SseLineOutcome {
1196                    immediate,
1197                    terminal: Some(vec![StreamDelta::Error {
1198                        message,
1199                        kind: StreamErrorKind::ServerError,
1200                    }]),
1201                };
1202            }
1203            SseProcessResult::Sentinel => {
1204                let sr = stop_reason.unwrap_or_else(|| fallback_stream_stop_reason(tool_calls));
1205                let terminal = build_stream_end_deltas(tool_calls, usage.take(), sr);
1206                return SseLineOutcome {
1207                    immediate,
1208                    terminal: Some(terminal),
1209                };
1210            }
1211        }
1212    }
1213    SseLineOutcome {
1214        immediate,
1215        terminal: None,
1216    }
1217}
1218
1219/// Helper to emit tool call deltas and done event.
1220fn build_stream_end_deltas(
1221    tool_calls: &std::collections::HashMap<usize, ToolCallAccumulator>,
1222    usage: Option<Usage>,
1223    stop_reason: StopReason,
1224) -> Vec<StreamDelta> {
1225    let mut deltas = Vec::new();
1226
1227    if matches!(stop_reason, StopReason::ToolUse) {
1228        // `idx` comes from the wire `tool_calls[].index`; use saturating_add so
1229        // a hostile `usize::MAX` index cannot overflow-panic in debug builds.
1230        // StreamAccumulator sorts by index so order stays stable.
1231        for (idx, tool) in tool_calls {
1232            let block_index = idx.saturating_add(1);
1233            deltas.push(StreamDelta::ToolUseStart {
1234                id: tool.id.clone(),
1235                name: tool.name.clone(),
1236                block_index,
1237                thought_signature: None,
1238            });
1239            deltas.push(StreamDelta::ToolInputDelta {
1240                id: tool.id.clone(),
1241                delta: tool.arguments.clone(),
1242                block_index,
1243            });
1244        }
1245    }
1246
1247    // Emit usage
1248    if let Some(u) = usage {
1249        deltas.push(StreamDelta::Usage(u));
1250    }
1251
1252    // Emit done
1253    deltas.push(StreamDelta::Done {
1254        stop_reason: Some(stop_reason),
1255    });
1256
1257    deltas
1258}
1259
1260/// Result of processing an SSE chunk.
1261enum SseProcessResult {
1262    /// Emit a text delta.
1263    TextDelta(String),
1264    /// Emit structured refusal text and mark the turn as refused.
1265    RefusalDelta(String),
1266    /// Emit a thinking/reasoning delta (reasoning-model fallback when the model
1267    /// streams its output via `reasoning_content`/`reasoning` and `content` is
1268    /// empty, mirroring the non-streaming `build_content_blocks` fallback).
1269    ThinkingDelta(String),
1270    /// Update tool call accumulator (index, optional id, optional name, optional args).
1271    ToolCallUpdate {
1272        index: usize,
1273        id: Option<String>,
1274        name: Option<String>,
1275        arguments: Option<String>,
1276    },
1277    /// Usage information.
1278    Usage(Usage),
1279    /// Stream is done with a stop reason.
1280    Done(StopReason),
1281    /// The provider emitted a malformed JSON event.
1282    Malformed(String),
1283    /// Stream sentinel [DONE] was received.
1284    Sentinel,
1285}
1286
1287/// Process an SSE data line and return results to apply.
1288fn process_sse_data(data: &str) -> Vec<SseProcessResult> {
1289    if data == "[DONE]" {
1290        return vec![SseProcessResult::Sentinel];
1291    }
1292
1293    let chunk = match serde_json::from_str::<SseChunk>(data) {
1294        Ok(chunk) => chunk,
1295        Err(error) => {
1296            return vec![SseProcessResult::Malformed(format!(
1297                "invalid OpenAI Chat Completions stream event: {error}"
1298            ))];
1299        }
1300    };
1301
1302    let mut results = Vec::new();
1303
1304    // Extract usage if present
1305    if let Some(u) = chunk.usage {
1306        results.push(SseProcessResult::Usage(Usage {
1307            input_tokens: u.prompt_tokens,
1308            output_tokens: u.completion_tokens,
1309            cached_input_tokens: u
1310                .prompt_tokens_details
1311                .as_ref()
1312                .map_or(0, |details| details.cached_tokens),
1313            cache_creation_input_tokens: u
1314                .prompt_tokens_details
1315                .as_ref()
1316                .map_or(0, |details| details.cache_write_tokens),
1317        }));
1318    }
1319
1320    // Process choices
1321    if let Some(choice) = chunk.choices.into_iter().next() {
1322        let content = choice.delta.content.filter(|content| !content.is_empty());
1323        let refusal = choice.delta.refusal.filter(|refusal| !refusal.is_empty());
1324        let has_visible_output = content.is_some() || refusal.is_some();
1325
1326        if let Some(content) = content {
1327            results.push(SseProcessResult::TextDelta(content));
1328        }
1329        if let Some(refusal) = refusal {
1330            results.push(SseProcessResult::RefusalDelta(refusal));
1331        }
1332        if !has_visible_output
1333            && let Some(reasoning) = choice
1334                .delta
1335                .reasoning_content
1336                .as_deref()
1337                .or(choice.delta.reasoning.as_deref())
1338                .filter(|reasoning| !reasoning.is_empty())
1339        {
1340            results.push(SseProcessResult::ThinkingDelta(reasoning.to_owned()));
1341        }
1342
1343        // Handle tool call deltas
1344        if let Some(tc_deltas) = choice.delta.tool_calls {
1345            for tc in tc_deltas {
1346                results.push(SseProcessResult::ToolCallUpdate {
1347                    index: tc.index,
1348                    id: tc.id,
1349                    name: tc.function.as_ref().and_then(|f| f.name.clone()),
1350                    arguments: tc.function.as_ref().and_then(|f| f.arguments.clone()),
1351                });
1352            }
1353        }
1354
1355        // Check for finish reason
1356        if let Some(finish_reason) = choice.finish_reason {
1357            results.push(SseProcessResult::Done(map_finish_reason(&finish_reason)));
1358        }
1359    }
1360
1361    results
1362}
1363
1364fn use_max_tokens_alias(base_url: &str) -> bool {
1365    base_url.contains("moonshot.ai")
1366        || base_url.contains("api.z.ai")
1367        || base_url.contains("minimax.io")
1368}
1369
1370/// Every `OpenAI`-compatible endpoint accepts `stream_options.include_usage`;
1371/// requesting it everywhere ensures `OpenRouter` / `Baseten` / local streams
1372/// carry a usage frame so `total_usage` and downstream cost ledgers are
1373/// populated (issue #302), not just first-party `api.openai.com` turns.
1374const fn use_stream_usage_options(_base_url: &str) -> bool {
1375    true
1376}
1377
1378/// `OpenRouter` requires a separate top-level `usage: { include: true }` flag
1379/// (distinct from `stream_options.include_usage`) to emit a usage frame.
1380fn use_openrouter_usage_options(base_url: &str) -> bool {
1381    base_url.contains("openrouter.ai")
1382}
1383
1384/// Infer the stop reason at a valid `[DONE]` sentinel when the provider omitted
1385/// an explicit `finish_reason`.
1386fn fallback_stream_stop_reason(
1387    tool_calls: &std::collections::HashMap<usize, ToolCallAccumulator>,
1388) -> StopReason {
1389    if tool_calls.is_empty() {
1390        StopReason::EndTurn
1391    } else {
1392        StopReason::ToolUse
1393    }
1394}
1395
1396/// Map an HTTP status + body into a [`ChatOutcome`], parsing the success body
1397/// into a [`ChatResponse`].
1398fn decode_chat_response(
1399    status: StatusCode,
1400    bytes: &[u8],
1401    retry_after: Option<std::time::Duration>,
1402) -> Result<ChatOutcome> {
1403    if status == StatusCode::TOO_MANY_REQUESTS {
1404        return Ok(ChatOutcome::RateLimited(retry_after));
1405    }
1406
1407    if status.is_server_error() {
1408        let body = String::from_utf8_lossy(bytes);
1409        log::error!("OpenAI server error status={status} body={body}");
1410        return Ok(ChatOutcome::ServerError(body.into_owned()));
1411    }
1412
1413    if status.is_client_error() {
1414        let body = String::from_utf8_lossy(bytes);
1415        log::warn!("OpenAI client error status={status} body={body}");
1416        return Ok(ChatOutcome::InvalidRequest(body.into_owned()));
1417    }
1418
1419    let api_response: ApiChatResponse = serde_json::from_slice(bytes)
1420        .map_err(|e| anyhow::anyhow!("failed to parse response: {e}"))?;
1421
1422    let choice = api_response
1423        .choices
1424        .into_iter()
1425        .next()
1426        .ok_or_else(|| anyhow::anyhow!("no choices in response"))?;
1427
1428    let stop_reason = if refusal_text(&choice.message).is_some() {
1429        Some(StopReason::Refusal)
1430    } else {
1431        choice.finish_reason.as_deref().map(map_finish_reason)
1432    };
1433    let mut content = build_content_blocks(&choice.message);
1434    if !matches!(stop_reason, Some(StopReason::ToolUse)) {
1435        content.retain(|block| !matches!(block, ContentBlock::ToolUse { .. }));
1436    }
1437
1438    Ok(ChatOutcome::Success(ChatResponse {
1439        id: api_response.id,
1440        content,
1441        model: api_response.model,
1442        stop_reason,
1443        usage: Usage {
1444            input_tokens: api_response.usage.prompt_tokens,
1445            output_tokens: api_response.usage.completion_tokens,
1446            cached_input_tokens: api_response
1447                .usage
1448                .prompt_tokens_details
1449                .as_ref()
1450                .map_or(0, |details| details.cached_tokens),
1451            cache_creation_input_tokens: api_response
1452                .usage
1453                .prompt_tokens_details
1454                .as_ref()
1455                .map_or(0, |details| details.cache_write_tokens),
1456        },
1457    }))
1458}
1459
1460fn map_finish_reason(finish_reason: &str) -> StopReason {
1461    match finish_reason {
1462        "stop" => StopReason::EndTurn,
1463        "tool_calls" => StopReason::ToolUse,
1464        "length" => StopReason::MaxTokens,
1465        "content_filter" | "refusal" | "sensitive" => StopReason::Refusal,
1466        "network_error" => StopReason::Unknown,
1467        unknown => {
1468            log::debug!("Unknown finish_reason from OpenAI-compatible API: {unknown}");
1469            StopReason::Unknown
1470        }
1471    }
1472}
1473
1474fn build_chat_api_reasoning(
1475    config: Option<&OpenAIReasoningConfig>,
1476    first_party_wire: bool,
1477) -> Option<ApiChatReasoning> {
1478    config
1479        .and_then(OpenAIReasoningConfig::effort)
1480        .map(|effort| {
1481            if first_party_wire {
1482                ApiChatReasoning {
1483                    reasoning_effort: Some(effort),
1484                    reasoning: None,
1485                }
1486            } else {
1487                ApiChatReasoning {
1488                    reasoning_effort: None,
1489                    reasoning: Some(ApiCompatibleReasoning { effort }),
1490                }
1491            }
1492        })
1493}
1494
1495const fn api_role(role: agent_sdk_foundation::llm::Role) -> ApiRole {
1496    match role {
1497        agent_sdk_foundation::llm::Role::User => ApiRole::User,
1498        agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
1499    }
1500}
1501
1502/// Convert a `Content::Blocks` message into the `OpenAI` wire messages it maps
1503/// to, appending them to `messages`.
1504///
1505/// Tool results become standalone `tool` messages; text, tool calls and (on
1506/// assistant tool-call turns) echoed-back reasoning collapse into a single
1507/// message.
1508fn append_block_messages(
1509    messages: &mut Vec<ApiMessage>,
1510    role: agent_sdk_foundation::llm::Role,
1511    blocks: &[ContentBlock],
1512) {
1513    let mut text_parts = Vec::new();
1514    let mut thinking_parts = Vec::new();
1515    let mut tool_calls = Vec::new();
1516
1517    for block in blocks {
1518        match block {
1519            ContentBlock::Text { text } => text_parts.push(text.clone()),
1520            ContentBlock::Thinking { thinking, .. } => {
1521                // DeepSeek-style thinking-mode multi-turn requires the prior
1522                // assistant reasoning_content to be echoed back on a tool-call
1523                // turn or the API 400s. Collected here; only carried into
1524                // reasoning_content below when this turn also has a tool call.
1525                thinking_parts.push(thinking.clone());
1526            }
1527            ContentBlock::RedactedThinking { .. }
1528            | ContentBlock::Image { .. }
1529            | ContentBlock::Document { .. } => {
1530                // These blocks are not sent to the OpenAI API
1531            }
1532            ContentBlock::ToolUse {
1533                id, name, input, ..
1534            } => {
1535                tool_calls.push(ApiToolCall {
1536                    id: id.clone(),
1537                    r#type: "function".to_owned(),
1538                    function: ApiFunctionCall {
1539                        name: name.clone(),
1540                        arguments: serde_json::to_string(input).unwrap_or_else(|_| "{}".to_owned()),
1541                    },
1542                });
1543            }
1544            ContentBlock::ToolResult {
1545                tool_use_id,
1546                content,
1547                ..
1548            } => {
1549                // Tool results are separate messages in OpenAI
1550                messages.push(ApiMessage {
1551                    role: ApiRole::Tool,
1552                    content: Some(content.clone()),
1553                    reasoning_content: None,
1554                    tool_calls: None,
1555                    tool_call_id: Some(tool_use_id.clone()),
1556                    prompt_cache_breakpoint: false,
1557                });
1558            }
1559            // `ContentBlock` is `#[non_exhaustive]`; a block kind this SDK
1560            // version cannot represent is not sent to OpenAI.
1561            _ => log::warn!("Skipping unrecognized OpenAI content block"),
1562        }
1563    }
1564
1565    let role = api_role(role);
1566
1567    // reasoning_content is only echoed back on an assistant turn that ALSO
1568    // carries a tool call — the one case DeepSeek's thinking-mode protocol
1569    // requires it. Per that protocol legacy `deepseek-reasoner` 400s if
1570    // reasoning_content appears in input at all, and DeepSeek V4 thinking-mode
1571    // only needs it on tool-call turns. So a plain reasoning-only assistant
1572    // turn (no tool call) does NOT carry reasoning_content, and it is never
1573    // attached to user messages.
1574    let reasoning_content =
1575        if role == ApiRole::Assistant && !thinking_parts.is_empty() && !tool_calls.is_empty() {
1576            Some(thinking_parts.join("\n"))
1577        } else {
1578            None
1579        };
1580
1581    // Add the message when it carries text, tool calls, or (for an assistant
1582    // turn) reasoning to echo back. Only emit if it's an assistant message or
1583    // has text content.
1584    let has_payload =
1585        !text_parts.is_empty() || !tool_calls.is_empty() || reasoning_content.is_some();
1586    if has_payload && (role == ApiRole::Assistant || !text_parts.is_empty()) {
1587        messages.push(ApiMessage {
1588            role,
1589            content: if text_parts.is_empty() {
1590                None
1591            } else {
1592                Some(text_parts.join("\n"))
1593            },
1594            reasoning_content,
1595            tool_calls: if tool_calls.is_empty() {
1596                None
1597            } else {
1598                Some(tool_calls)
1599            },
1600            tool_call_id: None,
1601            prompt_cache_breakpoint: false,
1602        });
1603    }
1604}
1605
1606#[cfg(test)]
1607fn build_api_messages(request: &ChatRequest) -> Vec<ApiMessage> {
1608    build_api_messages_with_cache(request, 0)
1609}
1610
1611fn build_api_messages_with_cache(
1612    request: &ChatRequest,
1613    explicit_cache_breakpoints: usize,
1614) -> Vec<ApiMessage> {
1615    let mut messages = Vec::new();
1616
1617    // Add system message first (OpenAI uses a separate message for system prompt)
1618    if !request.system.is_empty() {
1619        messages.push(ApiMessage {
1620            role: ApiRole::System,
1621            content: Some(request.system.clone()),
1622            reasoning_content: None,
1623            tool_calls: None,
1624            tool_call_id: None,
1625            prompt_cache_breakpoint: false,
1626        });
1627    }
1628
1629    // Convert SDK messages to OpenAI format
1630    for msg in &request.messages {
1631        match &msg.content {
1632            Content::Text(text) => {
1633                messages.push(ApiMessage {
1634                    role: api_role(msg.role),
1635                    content: Some(text.clone()),
1636                    reasoning_content: None,
1637                    tool_calls: None,
1638                    tool_call_id: None,
1639                    prompt_cache_breakpoint: false,
1640                });
1641            }
1642            Content::Blocks(blocks) => append_block_messages(&mut messages, msg.role, blocks),
1643        }
1644    }
1645
1646    apply_chat_cache_breakpoints(&mut messages, explicit_cache_breakpoints);
1647    messages
1648}
1649
1650fn apply_chat_cache_breakpoints(messages: &mut [ApiMessage], max_breakpoints: usize) {
1651    if max_breakpoints == 0 {
1652        return;
1653    }
1654
1655    let max_breakpoints = max_breakpoints.min(4);
1656    let message_indices: Vec<usize> = messages
1657        .iter()
1658        .enumerate()
1659        .filter_map(|(index, message)| message.content.as_ref().map(|_| index))
1660        .collect();
1661
1662    let mut selected = Vec::with_capacity(max_breakpoints);
1663    // Chat Completions has no marker field on tool definitions. A breakpoint on
1664    // the system message still closes the prefix after tools + system, so it is
1665    // the most stable representable boundary before conversation-tail markers.
1666    if let Some(system_index) = message_indices
1667        .iter()
1668        .copied()
1669        .find(|index| messages[*index].role == ApiRole::System)
1670    {
1671        selected.push(system_index);
1672    }
1673
1674    let remaining = max_breakpoints.saturating_sub(selected.len());
1675    let tail_candidates: Vec<usize> = message_indices
1676        .into_iter()
1677        .filter(|index| !selected.contains(index))
1678        .collect();
1679    let keep_from = tail_candidates.len().saturating_sub(remaining);
1680    selected.extend_from_slice(&tail_candidates[keep_from..]);
1681
1682    for index in selected {
1683        messages[index].prompt_cache_breakpoint = true;
1684    }
1685}
1686
1687fn convert_tool(t: agent_sdk_foundation::llm::Tool, normalize_for_openai_strict: bool) -> ApiTool {
1688    let mut parameters = t.input_schema;
1689    let strict =
1690        (normalize_for_openai_strict && normalize_strict_schema(&mut parameters)).then_some(true);
1691    ApiTool {
1692        r#type: "function".to_owned(),
1693        function: ApiFunction {
1694            name: t.name,
1695            description: t.description,
1696            parameters,
1697            strict,
1698        },
1699    }
1700}
1701
1702/// Non-empty reasoning text from an `OpenAI`-compatible response message, if any.
1703///
1704/// Prefers `DeepSeek`-style `reasoning_content`, falling back to the `reasoning`
1705/// field used by some `OpenRouter` upstreams.
1706fn reasoning_text(message: &ApiResponseMessage) -> Option<&str> {
1707    message
1708        .reasoning_content
1709        .as_deref()
1710        .or(message.reasoning.as_deref())
1711        .filter(|r| !r.is_empty())
1712}
1713
1714fn refusal_text(message: &ApiResponseMessage) -> Option<&str> {
1715    message
1716        .refusal
1717        .as_deref()
1718        .filter(|refusal| !refusal.is_empty())
1719}
1720
1721fn build_content_blocks(message: &ApiResponseMessage) -> Vec<ContentBlock> {
1722    let mut blocks = Vec::new();
1723
1724    let content = message
1725        .content
1726        .as_ref()
1727        .filter(|content| !content.is_empty());
1728    let refusal = refusal_text(message);
1729    let has_visible_output = content.is_some() || refusal.is_some();
1730    if let Some(content) = content {
1731        blocks.push(ContentBlock::Text {
1732            text: content.clone(),
1733        });
1734    }
1735    if let Some(refusal) = refusal {
1736        blocks.push(ContentBlock::Text {
1737            text: refusal.to_owned(),
1738        });
1739    }
1740    if !has_visible_output && let Some(reasoning) = reasoning_text(message) {
1741        // Reasoning-model fallback: when `content` is empty/absent but the model
1742        // produced reasoning tokens (DeepSeek-style answer-in-`reasoning_content`,
1743        // or any reasoning model truncated under a tight `max_tokens` before it
1744        // emitted visible content), surface the reasoning as a Thinking block so
1745        // the usable output is not silently dropped. This is a fallback only —
1746        // when `content` is present the reasoning is left untouched.
1747        blocks.push(ContentBlock::Thinking {
1748            thinking: reasoning.to_owned(),
1749            signature: None,
1750        });
1751    }
1752
1753    // Add tool calls if present
1754    if let Some(tool_calls) = &message.tool_calls {
1755        for tc in tool_calls {
1756            let input: serde_json::Value = serde_json::from_str(&tc.function.arguments)
1757                .unwrap_or_else(|_| serde_json::json!({}));
1758            blocks.push(ContentBlock::ToolUse {
1759                id: tc.id.clone(),
1760                name: tc.function.name.clone(),
1761                input,
1762                thought_signature: None,
1763            });
1764        }
1765    }
1766
1767    blocks
1768}
1769
1770// ============================================================================
1771// API Request Types
1772// ============================================================================
1773
1774#[derive(Serialize)]
1775struct ApiChatRequest<'a> {
1776    model: &'a str,
1777    messages: &'a [ApiMessage],
1778    #[serde(skip_serializing_if = "Option::is_none")]
1779    max_completion_tokens: Option<u32>,
1780    #[serde(skip_serializing_if = "Option::is_none")]
1781    max_tokens: Option<u32>,
1782    #[serde(skip_serializing_if = "Option::is_none")]
1783    tools: Option<&'a [ApiTool]>,
1784    #[serde(skip_serializing_if = "Option::is_none")]
1785    tool_choice: Option<ApiToolChoice>,
1786    #[serde(flatten)]
1787    reasoning: Option<ApiChatReasoning>,
1788    #[serde(skip_serializing_if = "Option::is_none")]
1789    response_format: Option<ApiResponseFormat>,
1790    #[serde(skip_serializing_if = "Option::is_none")]
1791    verbosity: Option<OpenAITextVerbosity>,
1792    #[serde(skip_serializing_if = "Option::is_none")]
1793    prompt_cache_options: Option<ApiPromptCacheOptions>,
1794    #[serde(skip_serializing_if = "Option::is_none")]
1795    store: Option<bool>,
1796    #[serde(skip_serializing_if = "Option::is_none")]
1797    parallel_tool_calls: Option<bool>,
1798    #[serde(skip_serializing_if = "Option::is_none")]
1799    safety_identifier: Option<&'a str>,
1800    #[serde(skip_serializing_if = "Option::is_none")]
1801    prompt_cache_key: Option<&'a str>,
1802}
1803
1804#[derive(Serialize)]
1805struct ApiChatRequestStreaming<'a> {
1806    model: &'a str,
1807    messages: &'a [ApiMessage],
1808    #[serde(skip_serializing_if = "Option::is_none")]
1809    max_completion_tokens: Option<u32>,
1810    #[serde(skip_serializing_if = "Option::is_none")]
1811    max_tokens: Option<u32>,
1812    #[serde(skip_serializing_if = "Option::is_none")]
1813    tools: Option<&'a [ApiTool]>,
1814    #[serde(skip_serializing_if = "Option::is_none")]
1815    tool_choice: Option<ApiToolChoice>,
1816    #[serde(flatten)]
1817    reasoning: Option<ApiChatReasoning>,
1818    #[serde(skip_serializing_if = "Option::is_none")]
1819    response_format: Option<ApiResponseFormat>,
1820    #[serde(skip_serializing_if = "Option::is_none")]
1821    verbosity: Option<OpenAITextVerbosity>,
1822    #[serde(skip_serializing_if = "Option::is_none")]
1823    prompt_cache_options: Option<ApiPromptCacheOptions>,
1824    #[serde(skip_serializing_if = "Option::is_none")]
1825    store: Option<bool>,
1826    #[serde(skip_serializing_if = "Option::is_none")]
1827    parallel_tool_calls: Option<bool>,
1828    #[serde(skip_serializing_if = "Option::is_none")]
1829    safety_identifier: Option<&'a str>,
1830    #[serde(skip_serializing_if = "Option::is_none")]
1831    prompt_cache_key: Option<&'a str>,
1832    #[serde(skip_serializing_if = "Option::is_none")]
1833    stream_options: Option<ApiStreamOptions>,
1834    #[serde(skip_serializing_if = "Option::is_none")]
1835    usage: Option<ApiOpenRouterUsageOptions>,
1836    stream: bool,
1837}
1838
1839/// `OpenAI` `tool_choice` wire format.
1840///
1841/// - `"auto"` — model decides.
1842/// - `{"type": "function", "function": {"name": "<name>"}}` — force a specific function.
1843#[derive(Serialize)]
1844#[serde(untagged)]
1845enum ApiToolChoice {
1846    Mode(&'static str),
1847    Named {
1848        #[serde(rename = "type")]
1849        choice_type: &'static str,
1850        function: ApiToolChoiceFunction,
1851    },
1852    AllowedTools {
1853        #[serde(rename = "type")]
1854        choice_type: &'static str,
1855        allowed_tools: ApiChatAllowedTools,
1856    },
1857}
1858
1859#[derive(Serialize)]
1860struct ApiToolChoiceFunction {
1861    name: String,
1862}
1863
1864#[derive(Serialize)]
1865struct ApiChatAllowedTools {
1866    mode: OpenAIAllowedToolsMode,
1867    tools: Vec<ApiChatAllowedTool>,
1868}
1869
1870#[derive(Serialize)]
1871struct ApiChatAllowedTool {
1872    #[serde(rename = "type")]
1873    tool_type: &'static str,
1874    function: ApiToolChoiceFunction,
1875}
1876
1877impl ApiToolChoice {
1878    fn from_tool_choice(tc: &ToolChoice) -> Self {
1879        match tc {
1880            ToolChoice::Auto => Self::Mode("auto"),
1881            ToolChoice::Tool(name) => Self::Named {
1882                choice_type: "function",
1883                function: ApiToolChoiceFunction { name: name.clone() },
1884            },
1885        }
1886    }
1887
1888    fn from_openai_tool_choice(choice: &OpenAIToolChoice) -> Self {
1889        match choice {
1890            OpenAIToolChoice::None => Self::Mode("none"),
1891            OpenAIToolChoice::Auto => Self::Mode("auto"),
1892            OpenAIToolChoice::Required => Self::Mode("required"),
1893            OpenAIToolChoice::Function(name) => Self::Named {
1894                choice_type: "function",
1895                function: ApiToolChoiceFunction { name: name.clone() },
1896            },
1897            OpenAIToolChoice::AllowedTools { mode, tools } => Self::AllowedTools {
1898                choice_type: "allowed_tools",
1899                allowed_tools: ApiChatAllowedTools {
1900                    mode: *mode,
1901                    tools: tools
1902                        .iter()
1903                        .map(|name| ApiChatAllowedTool {
1904                            tool_type: "function",
1905                            function: ApiToolChoiceFunction { name: name.clone() },
1906                        })
1907                        .collect(),
1908                },
1909            },
1910        }
1911    }
1912}
1913
1914/// `OpenAI` `response_format` wire format for structured outputs.
1915///
1916/// Emits `{"type": "json_schema", "json_schema": {"name", "schema", "strict"}}`.
1917#[derive(Serialize)]
1918struct ApiResponseFormat {
1919    #[serde(rename = "type")]
1920    format_type: &'static str,
1921    json_schema: ApiJsonSchema,
1922}
1923
1924#[derive(Serialize)]
1925struct ApiJsonSchema {
1926    name: String,
1927    schema: serde_json::Value,
1928    strict: bool,
1929}
1930
1931impl ApiResponseFormat {
1932    fn from_response_format(
1933        rf: &agent_sdk_foundation::llm::ResponseFormat,
1934        normalize_for_openai_strict: bool,
1935    ) -> Self {
1936        let mut schema = rf.schema.clone();
1937        if rf.strict && normalize_for_openai_strict {
1938            let _ = normalize_strict_schema(&mut schema);
1939        }
1940        Self {
1941            format_type: "json_schema",
1942            json_schema: ApiJsonSchema {
1943                name: rf.name.clone(),
1944                schema,
1945                strict: rf.strict,
1946            },
1947        }
1948    }
1949}
1950
1951#[derive(Clone, Copy, Serialize)]
1952struct ApiStreamOptions {
1953    include_usage: bool,
1954}
1955
1956/// `OpenRouter`'s top-level usage-accounting flag (`usage: { include: true }`),
1957/// distinct from `stream_options.include_usage`.
1958#[derive(Clone, Copy, Serialize)]
1959struct ApiOpenRouterUsageOptions {
1960    include: bool,
1961}
1962
1963#[derive(Serialize)]
1964struct ApiChatReasoning {
1965    #[serde(skip_serializing_if = "Option::is_none")]
1966    reasoning_effort: Option<OpenAIReasoningEffort>,
1967    #[serde(skip_serializing_if = "Option::is_none")]
1968    reasoning: Option<ApiCompatibleReasoning>,
1969}
1970
1971#[derive(Serialize)]
1972struct ApiCompatibleReasoning {
1973    effort: OpenAIReasoningEffort,
1974}
1975
1976#[derive(Clone, Copy, Serialize)]
1977struct ApiPromptCacheOptions {
1978    #[serde(skip_serializing_if = "Option::is_none")]
1979    mode: Option<OpenAIPromptCacheMode>,
1980    #[serde(skip_serializing_if = "Option::is_none")]
1981    ttl: Option<OpenAIPromptCacheTtl>,
1982}
1983
1984#[derive(Clone, Copy)]
1985struct ChatPromptCachePlan {
1986    options: Option<ApiPromptCacheOptions>,
1987    explicit_breakpoints: usize,
1988}
1989
1990impl ApiPromptCacheOptions {
1991    const fn new(mode: OpenAIPromptCacheMode) -> Self {
1992        Self {
1993            mode: Some(mode),
1994            ttl: None,
1995        }
1996    }
1997
1998    fn from_config(config: Option<&OpenAIReasoningConfig>) -> Option<Self> {
1999        let config = config?;
2000        let options = Self {
2001            mode: config.prompt_cache_mode(),
2002            ttl: config.prompt_cache_ttl(),
2003        };
2004        (options.mode.is_some() || options.ttl.is_some()).then_some(options)
2005    }
2006}
2007
2008struct ApiMessage {
2009    role: ApiRole,
2010    content: Option<String>,
2011    /// `DeepSeek`-style thinking-mode multi-turn requires the prior assistant
2012    /// `reasoning_content` to be echoed back on a tool-call turn or the API
2013    /// rejects it (HTTP 400). Carried back only for assistant turns that had a
2014    /// Thinking block AND a tool call; omitted entirely otherwise (including
2015    /// reasoning-only turns, since legacy `deepseek-reasoner` 400s if
2016    /// `reasoning_content` appears in input) so the normal path is unchanged.
2017    reasoning_content: Option<String>,
2018    tool_calls: Option<Vec<ApiToolCall>>,
2019    tool_call_id: Option<String>,
2020    prompt_cache_breakpoint: bool,
2021}
2022
2023impl Serialize for ApiMessage {
2024    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
2025    where
2026        S: serde::Serializer,
2027    {
2028        let mut message = serializer.serialize_struct("ApiMessage", 5)?;
2029        message.serialize_field("role", &self.role)?;
2030        if let Some(content) = self.content.as_deref() {
2031            if self.prompt_cache_breakpoint {
2032                message.serialize_field(
2033                    "content",
2034                    &[ApiChatTextPart {
2035                        part_type: "text",
2036                        text: content,
2037                        prompt_cache_breakpoint: ApiChatPromptCacheBreakpoint {
2038                            mode: OpenAIPromptCacheMode::Explicit,
2039                        },
2040                    }],
2041                )?;
2042            } else {
2043                message.serialize_field("content", content)?;
2044            }
2045        }
2046        if let Some(reasoning_content) = self.reasoning_content.as_deref() {
2047            message.serialize_field("reasoning_content", reasoning_content)?;
2048        }
2049        if let Some(tool_calls) = self.tool_calls.as_ref() {
2050            message.serialize_field("tool_calls", tool_calls)?;
2051        }
2052        if let Some(tool_call_id) = self.tool_call_id.as_deref() {
2053            message.serialize_field("tool_call_id", tool_call_id)?;
2054        }
2055        message.end()
2056    }
2057}
2058
2059#[derive(Serialize)]
2060struct ApiChatTextPart<'a> {
2061    #[serde(rename = "type")]
2062    part_type: &'static str,
2063    text: &'a str,
2064    prompt_cache_breakpoint: ApiChatPromptCacheBreakpoint,
2065}
2066
2067#[derive(Clone, Copy, Serialize)]
2068struct ApiChatPromptCacheBreakpoint {
2069    mode: OpenAIPromptCacheMode,
2070}
2071
2072#[derive(Debug, Serialize, PartialEq, Eq)]
2073#[serde(rename_all = "lowercase")]
2074enum ApiRole {
2075    System,
2076    User,
2077    Assistant,
2078    Tool,
2079}
2080
2081#[derive(Serialize)]
2082struct ApiToolCall {
2083    id: String,
2084    r#type: String,
2085    function: ApiFunctionCall,
2086}
2087
2088#[derive(Serialize)]
2089struct ApiFunctionCall {
2090    name: String,
2091    arguments: String,
2092}
2093
2094#[derive(Serialize)]
2095struct ApiTool {
2096    r#type: String,
2097    function: ApiFunction,
2098}
2099
2100#[derive(Serialize)]
2101struct ApiFunction {
2102    name: String,
2103    description: String,
2104    parameters: serde_json::Value,
2105    #[serde(skip_serializing_if = "Option::is_none")]
2106    strict: Option<bool>,
2107}
2108
2109// ============================================================================
2110// API Response Types
2111// ============================================================================
2112
2113#[derive(Deserialize)]
2114struct ApiChatResponse {
2115    id: String,
2116    choices: Vec<ApiChoice>,
2117    model: String,
2118    usage: ApiUsage,
2119}
2120
2121#[derive(Deserialize)]
2122struct ApiChoice {
2123    message: ApiResponseMessage,
2124    finish_reason: Option<String>,
2125}
2126
2127#[derive(Deserialize)]
2128struct ApiResponseMessage {
2129    content: Option<String>,
2130    #[serde(default)]
2131    refusal: Option<String>,
2132    tool_calls: Option<Vec<ApiResponseToolCall>>,
2133    /// `DeepSeek`-style chain-of-thought, returned at the same level as
2134    /// `content` (`DeepSeek` V4 / some `OpenRouter` providers).
2135    #[serde(default)]
2136    reasoning_content: Option<String>,
2137    /// `OpenRouter` normalizes reasoning under a `reasoning` field for some
2138    /// upstreams; treated as an equivalent fallback to `reasoning_content`.
2139    #[serde(default)]
2140    reasoning: Option<String>,
2141}
2142
2143#[derive(Deserialize)]
2144struct ApiResponseToolCall {
2145    id: String,
2146    function: ApiResponseFunctionCall,
2147}
2148
2149#[derive(Deserialize)]
2150struct ApiResponseFunctionCall {
2151    name: String,
2152    arguments: String,
2153}
2154
2155#[derive(Deserialize)]
2156struct ApiUsage {
2157    #[serde(deserialize_with = "deserialize_u32_from_number")]
2158    prompt_tokens: u32,
2159    #[serde(deserialize_with = "deserialize_u32_from_number")]
2160    completion_tokens: u32,
2161    #[serde(default)]
2162    prompt_tokens_details: Option<ApiPromptTokensDetails>,
2163}
2164
2165#[derive(Deserialize)]
2166struct ApiPromptTokensDetails {
2167    #[serde(default, deserialize_with = "deserialize_u32_from_number")]
2168    cached_tokens: u32,
2169    #[serde(default, deserialize_with = "deserialize_u32_from_number")]
2170    cache_write_tokens: u32,
2171}
2172
2173// ============================================================================
2174// SSE Streaming Types
2175// ============================================================================
2176
2177/// Accumulator for tool call state across stream deltas.
2178struct ToolCallAccumulator {
2179    id: String,
2180    name: String,
2181    arguments: String,
2182}
2183
2184/// A single chunk in `OpenAI`'s SSE stream.
2185#[derive(Deserialize)]
2186struct SseChunk {
2187    // A usage-only frame (OpenAI's trailing chunk, OpenRouter, etc.) may omit
2188    // `choices` entirely; without `default` it fails to deserialize and the
2189    // usage frame is dropped silently.
2190    #[serde(default)]
2191    choices: Vec<SseChoice>,
2192    #[serde(default)]
2193    usage: Option<SseUsage>,
2194}
2195
2196#[derive(Deserialize)]
2197struct SseChoice {
2198    delta: SseDelta,
2199    finish_reason: Option<String>,
2200}
2201
2202#[derive(Deserialize)]
2203struct SseDelta {
2204    content: Option<String>,
2205    #[serde(default)]
2206    refusal: Option<String>,
2207    tool_calls: Option<Vec<SseToolCallDelta>>,
2208    /// `DeepSeek`-style streamed chain-of-thought, returned at the same level as
2209    /// `content` (`DeepSeek` V4 / some `OpenRouter` providers).
2210    #[serde(default)]
2211    reasoning_content: Option<String>,
2212    /// `OpenRouter` normalizes streamed reasoning under a `reasoning` field for
2213    /// some upstreams; treated as an equivalent fallback to `reasoning_content`.
2214    #[serde(default)]
2215    reasoning: Option<String>,
2216}
2217
2218#[derive(Deserialize)]
2219struct SseToolCallDelta {
2220    index: usize,
2221    id: Option<String>,
2222    function: Option<SseFunctionDelta>,
2223}
2224
2225#[derive(Deserialize)]
2226struct SseFunctionDelta {
2227    name: Option<String>,
2228    arguments: Option<String>,
2229}
2230
2231#[derive(Deserialize)]
2232struct SseUsage {
2233    #[serde(deserialize_with = "deserialize_u32_from_number")]
2234    prompt_tokens: u32,
2235    #[serde(deserialize_with = "deserialize_u32_from_number")]
2236    completion_tokens: u32,
2237    #[serde(default)]
2238    prompt_tokens_details: Option<ApiPromptTokensDetails>,
2239}
2240
2241fn deserialize_u32_from_number<'de, D>(deserializer: D) -> std::result::Result<u32, D::Error>
2242where
2243    D: serde::Deserializer<'de>,
2244{
2245    #[derive(Deserialize)]
2246    #[serde(untagged)]
2247    enum NumberLike {
2248        U64(u64),
2249        F64(f64),
2250    }
2251
2252    match NumberLike::deserialize(deserializer)? {
2253        NumberLike::U64(v) => u32::try_from(v)
2254            .map_err(|_| D::Error::custom(format!("token count out of range for u32: {v}"))),
2255        NumberLike::F64(v) => {
2256            if v.is_finite() && v >= 0.0 && v.fract() == 0.0 && v <= f64::from(u32::MAX) {
2257                v.to_string().parse::<u32>().map_err(|e| {
2258                    D::Error::custom(format!(
2259                        "failed to convert integer-compatible token count {v} to u32: {e}"
2260                    ))
2261                })
2262            } else {
2263                Err(D::Error::custom(format!(
2264                    "token count must be a non-negative integer-compatible number, got {v}"
2265                )))
2266            }
2267        }
2268    }
2269}
2270
2271#[cfg(test)]
2272mod tests {
2273    use super::*;
2274    use anyhow::Context as _;
2275
2276    const OPENAI_MODELS_FIXTURE: &str = r#"{
2277      "object": "list",
2278      "data": [
2279        {"id": "gpt-5.4", "object": "model", "owned_by": "openai"},
2280        {"id": "gpt-4o", "object": "model", "owned_by": "openai"}
2281      ]
2282    }"#;
2283
2284    fn function_tool(name: &str) -> agent_sdk_foundation::llm::Tool {
2285        agent_sdk_foundation::llm::Tool {
2286            name: name.to_owned(),
2287            description: format!("Call {name}"),
2288            input_schema: serde_json::json!({"type": "object"}),
2289            display_name: name.to_owned(),
2290            tier: agent_sdk_foundation::ToolTier::Observe,
2291        }
2292    }
2293
2294    #[test]
2295    fn parse_models_list_reads_ids() -> anyhow::Result<()> {
2296        let models = parse_models_list(OPENAI_MODELS_FIXTURE)?;
2297        assert_eq!(models.len(), 2);
2298        assert_eq!(models[0].id, "gpt-5.4");
2299        assert_eq!(models[1].id, "gpt-4o");
2300        // The Chat Completions list endpoint reports no name or limits.
2301        assert_eq!(models[0].display_name, None);
2302        assert_eq!(models[0].context_window, None);
2303        Ok(())
2304    }
2305
2306    // ===================
2307    // Constructor Tests
2308    // ===================
2309
2310    #[test]
2311    fn test_new_creates_provider_with_custom_model() {
2312        let provider = OpenAIProvider::new("test-api-key".to_string(), "custom-model".to_string());
2313
2314        assert_eq!(provider.model(), "custom-model");
2315        assert_eq!(provider.provider(), "openai");
2316        assert_eq!(provider.base_url, DEFAULT_BASE_URL);
2317    }
2318
2319    #[test]
2320    fn test_with_base_url_creates_provider_with_custom_url() {
2321        let provider = OpenAIProvider::with_base_url(
2322            "test-api-key".to_string(),
2323            "llama3".to_string(),
2324            "http://localhost:11434/v1".to_string(),
2325        );
2326
2327        assert_eq!(provider.model(), "llama3");
2328        assert_eq!(provider.base_url, "http://localhost:11434/v1");
2329    }
2330
2331    #[test]
2332    fn test_gpt4o_factory_creates_gpt4o_provider() {
2333        let provider = OpenAIProvider::gpt4o("test-api-key".to_string());
2334
2335        assert_eq!(provider.model(), MODEL_GPT4O);
2336        assert_eq!(provider.provider(), "openai");
2337    }
2338
2339    #[test]
2340    fn test_gpt4o_mini_factory_creates_gpt4o_mini_provider() {
2341        let provider = OpenAIProvider::gpt4o_mini("test-api-key".to_string());
2342
2343        assert_eq!(provider.model(), MODEL_GPT4O_MINI);
2344        assert_eq!(provider.provider(), "openai");
2345    }
2346
2347    #[test]
2348    fn test_gpt52_thinking_factory_creates_provider() {
2349        let provider = OpenAIProvider::gpt52_thinking("test-api-key".to_string());
2350
2351        assert_eq!(provider.model(), MODEL_GPT52_THINKING);
2352        assert_eq!(provider.provider(), "openai");
2353    }
2354
2355    #[test]
2356    fn test_gpt54_factory_creates_provider() {
2357        let provider = OpenAIProvider::gpt54("test-api-key".to_string());
2358
2359        assert_eq!(provider.model(), MODEL_GPT54);
2360        assert_eq!(provider.provider(), "openai");
2361    }
2362
2363    #[test]
2364    fn test_gpt56_factories_create_expected_providers() {
2365        for (provider, expected_model) in [
2366            (
2367                OpenAIProvider::gpt56("test-api-key".to_string()),
2368                MODEL_GPT56,
2369            ),
2370            (
2371                OpenAIProvider::gpt56_sol("test-api-key".to_string()),
2372                MODEL_GPT56_SOL,
2373            ),
2374            (
2375                OpenAIProvider::gpt56_terra("test-api-key".to_string()),
2376                MODEL_GPT56_TERRA,
2377            ),
2378            (
2379                OpenAIProvider::gpt56_luna("test-api-key".to_string()),
2380                MODEL_GPT56_LUNA,
2381            ),
2382        ] {
2383            assert_eq!(provider.model(), expected_model);
2384            assert_eq!(provider.provider(), "openai");
2385            assert_eq!(provider.default_max_tokens(), 128_000);
2386        }
2387    }
2388
2389    #[test]
2390    fn test_gpt53_codex_factory_creates_provider() {
2391        let provider = OpenAIProvider::gpt53_codex("test-api-key".to_string());
2392
2393        assert_eq!(provider.model(), MODEL_GPT53_CODEX);
2394        assert_eq!(provider.provider(), "openai");
2395    }
2396
2397    #[test]
2398    fn test_codex_factory_points_to_latest_codex_model() {
2399        let provider = OpenAIProvider::codex("test-api-key".to_string());
2400
2401        assert_eq!(provider.model(), MODEL_GPT53_CODEX);
2402        assert_eq!(provider.provider(), "openai");
2403    }
2404
2405    #[test]
2406    fn test_gpt5_factory_creates_gpt5_provider() {
2407        let provider = OpenAIProvider::gpt5("test-api-key".to_string());
2408
2409        assert_eq!(provider.model(), MODEL_GPT5);
2410        assert_eq!(provider.provider(), "openai");
2411    }
2412
2413    #[test]
2414    fn test_gpt5_mini_factory_creates_provider() {
2415        let provider = OpenAIProvider::gpt5_mini("test-api-key".to_string());
2416
2417        assert_eq!(provider.model(), MODEL_GPT5_MINI);
2418        assert_eq!(provider.provider(), "openai");
2419    }
2420
2421    #[test]
2422    fn test_o3_factory_creates_o3_provider() {
2423        let provider = OpenAIProvider::o3("test-api-key".to_string());
2424
2425        assert_eq!(provider.model(), MODEL_O3);
2426        assert_eq!(provider.provider(), "openai");
2427    }
2428
2429    #[test]
2430    fn test_o4_mini_factory_creates_o4_mini_provider() {
2431        let provider = OpenAIProvider::o4_mini("test-api-key".to_string());
2432
2433        assert_eq!(provider.model(), MODEL_O4_MINI);
2434        assert_eq!(provider.provider(), "openai");
2435    }
2436
2437    #[test]
2438    fn test_o1_factory_creates_o1_provider() {
2439        let provider = OpenAIProvider::o1("test-api-key".to_string());
2440
2441        assert_eq!(provider.model(), MODEL_O1);
2442        assert_eq!(provider.provider(), "openai");
2443    }
2444
2445    #[test]
2446    fn test_gpt41_factory_creates_gpt41_provider() {
2447        let provider = OpenAIProvider::gpt41("test-api-key".to_string());
2448
2449        assert_eq!(provider.model(), MODEL_GPT41);
2450        assert_eq!(provider.provider(), "openai");
2451    }
2452
2453    #[test]
2454    fn test_kimi_factory_creates_provider_with_kimi_base_url() {
2455        let provider = OpenAIProvider::kimi("test-api-key".to_string(), "kimi-custom".to_string());
2456
2457        assert_eq!(provider.model(), "kimi-custom");
2458        assert_eq!(provider.base_url, BASE_URL_KIMI);
2459        assert_eq!(provider.provider(), "openai");
2460    }
2461
2462    #[test]
2463    fn test_kimi_k2_5_factory_creates_provider() {
2464        let provider = OpenAIProvider::kimi_k2_5("test-api-key".to_string());
2465
2466        assert_eq!(provider.model(), MODEL_KIMI_K2_5);
2467        assert_eq!(provider.base_url, BASE_URL_KIMI);
2468        assert_eq!(provider.provider(), "openai");
2469    }
2470
2471    #[test]
2472    fn test_kimi_k2_thinking_factory_creates_provider() {
2473        let provider = OpenAIProvider::kimi_k2_thinking("test-api-key".to_string());
2474
2475        assert_eq!(provider.model(), MODEL_KIMI_K2_THINKING);
2476        assert_eq!(provider.base_url, BASE_URL_KIMI);
2477        assert_eq!(provider.provider(), "openai");
2478    }
2479
2480    #[test]
2481    fn test_zai_factory_creates_provider_with_zai_base_url() {
2482        let provider = OpenAIProvider::zai("test-api-key".to_string(), "glm-custom".to_string());
2483
2484        assert_eq!(provider.model(), "glm-custom");
2485        assert_eq!(provider.base_url, BASE_URL_ZAI);
2486        assert_eq!(provider.provider(), "openai");
2487    }
2488
2489    #[test]
2490    fn test_zai_glm5_factory_creates_provider() {
2491        let provider = OpenAIProvider::zai_glm5("test-api-key".to_string());
2492
2493        assert_eq!(provider.model(), MODEL_ZAI_GLM5);
2494        assert_eq!(provider.base_url, BASE_URL_ZAI);
2495        assert_eq!(provider.provider(), "openai");
2496    }
2497
2498    #[test]
2499    fn test_minimax_factory_creates_provider_with_minimax_base_url() {
2500        let provider =
2501            OpenAIProvider::minimax("test-api-key".to_string(), "minimax-custom".to_string());
2502
2503        assert_eq!(provider.model(), "minimax-custom");
2504        assert_eq!(provider.base_url, BASE_URL_MINIMAX);
2505        assert_eq!(provider.provider(), "openai");
2506    }
2507
2508    #[test]
2509    fn test_minimax_m2_5_factory_creates_provider() {
2510        let provider = OpenAIProvider::minimax_m2_5("test-api-key".to_string());
2511
2512        assert_eq!(provider.model(), MODEL_MINIMAX_M2_5);
2513        assert_eq!(provider.base_url, BASE_URL_MINIMAX);
2514        assert_eq!(provider.provider(), "openai");
2515    }
2516
2517    // ===================
2518    // Model Constants Tests
2519    // ===================
2520
2521    #[test]
2522    fn test_model_constants_have_expected_values() {
2523        // GPT-5.6 series
2524        assert_eq!(MODEL_GPT56, "gpt-5.6");
2525        assert_eq!(MODEL_GPT56_SOL, "gpt-5.6-sol");
2526        assert_eq!(MODEL_GPT56_TERRA, "gpt-5.6-terra");
2527        assert_eq!(MODEL_GPT56_LUNA, "gpt-5.6-luna");
2528        // GPT-5.4 / GPT-5.3 Codex
2529        assert_eq!(MODEL_GPT54, "gpt-5.4");
2530        assert_eq!(MODEL_GPT53_CODEX, "gpt-5.3-codex");
2531        // GPT-5.2 series
2532        assert_eq!(MODEL_GPT52_INSTANT, "gpt-5.2-instant");
2533        assert_eq!(MODEL_GPT52_THINKING, "gpt-5.2-thinking");
2534        assert_eq!(MODEL_GPT52_PRO, "gpt-5.2-pro");
2535        assert_eq!(MODEL_GPT52_CODEX, "gpt-5.2-codex");
2536        // GPT-5 series
2537        assert_eq!(MODEL_GPT5, "gpt-5");
2538        assert_eq!(MODEL_GPT5_MINI, "gpt-5-mini");
2539        assert_eq!(MODEL_GPT5_NANO, "gpt-5-nano");
2540        // o-series
2541        assert_eq!(MODEL_O3, "o3");
2542        assert_eq!(MODEL_O3_MINI, "o3-mini");
2543        assert_eq!(MODEL_O4_MINI, "o4-mini");
2544        assert_eq!(MODEL_O1, "o1");
2545        assert_eq!(MODEL_O1_MINI, "o1-mini");
2546        // GPT-4.1 series
2547        assert_eq!(MODEL_GPT41, "gpt-4.1");
2548        assert_eq!(MODEL_GPT41_MINI, "gpt-4.1-mini");
2549        assert_eq!(MODEL_GPT41_NANO, "gpt-4.1-nano");
2550        // GPT-4o series
2551        assert_eq!(MODEL_GPT4O, "gpt-4o");
2552        assert_eq!(MODEL_GPT4O_MINI, "gpt-4o-mini");
2553        // OpenAI-compatible vendor defaults
2554        assert_eq!(MODEL_KIMI_K2_5, "kimi-k2.5");
2555        assert_eq!(MODEL_KIMI_K2_THINKING, "kimi-k2-thinking");
2556        assert_eq!(MODEL_ZAI_GLM5, "glm-5");
2557        assert_eq!(MODEL_MINIMAX_M2_5, "MiniMax-M2.5");
2558        assert_eq!(BASE_URL_KIMI, "https://api.moonshot.ai/v1");
2559        assert_eq!(BASE_URL_ZAI, "https://api.z.ai/api/paas/v4");
2560        assert_eq!(BASE_URL_MINIMAX, "https://api.minimax.io/v1");
2561    }
2562
2563    // ===================
2564    // Clone Tests
2565    // ===================
2566
2567    #[test]
2568    fn test_provider_is_cloneable() {
2569        let provider = OpenAIProvider::new("test-api-key".to_string(), "test-model".to_string());
2570        let cloned = provider.clone();
2571
2572        assert_eq!(provider.model(), cloned.model());
2573        assert_eq!(provider.provider(), cloned.provider());
2574        assert_eq!(provider.base_url, cloned.base_url);
2575    }
2576
2577    // ===================
2578    // API Type Serialization Tests
2579    // ===================
2580
2581    #[test]
2582    fn test_api_role_serialization() {
2583        let system_role = ApiRole::System;
2584        let user_role = ApiRole::User;
2585        let assistant_role = ApiRole::Assistant;
2586        let tool_role = ApiRole::Tool;
2587
2588        assert_eq!(serde_json::to_string(&system_role).unwrap(), "\"system\"");
2589        assert_eq!(serde_json::to_string(&user_role).unwrap(), "\"user\"");
2590        assert_eq!(
2591            serde_json::to_string(&assistant_role).unwrap(),
2592            "\"assistant\""
2593        );
2594        assert_eq!(serde_json::to_string(&tool_role).unwrap(), "\"tool\"");
2595    }
2596
2597    #[test]
2598    fn test_api_message_serialization_simple() {
2599        let message = ApiMessage {
2600            role: ApiRole::User,
2601            content: Some("Hello, world!".to_string()),
2602            reasoning_content: None,
2603            tool_calls: None,
2604            tool_call_id: None,
2605            prompt_cache_breakpoint: false,
2606        };
2607
2608        let json = serde_json::to_string(&message).unwrap();
2609        assert!(json.contains("\"role\":\"user\""));
2610        assert!(json.contains("\"content\":\"Hello, world!\""));
2611        // Optional fields should be omitted
2612        assert!(!json.contains("tool_calls"));
2613        assert!(!json.contains("tool_call_id"));
2614    }
2615
2616    #[test]
2617    fn test_api_message_serialization_with_tool_calls() {
2618        let message = ApiMessage {
2619            role: ApiRole::Assistant,
2620            content: Some("Let me help.".to_string()),
2621            reasoning_content: None,
2622            tool_calls: Some(vec![ApiToolCall {
2623                id: "call_123".to_string(),
2624                r#type: "function".to_string(),
2625                function: ApiFunctionCall {
2626                    name: "read_file".to_string(),
2627                    arguments: "{\"path\": \"/test.txt\"}".to_string(),
2628                },
2629            }]),
2630            tool_call_id: None,
2631            prompt_cache_breakpoint: false,
2632        };
2633
2634        let json = serde_json::to_string(&message).unwrap();
2635        assert!(json.contains("\"role\":\"assistant\""));
2636        assert!(json.contains("\"tool_calls\""));
2637        assert!(json.contains("\"id\":\"call_123\""));
2638        assert!(json.contains("\"type\":\"function\""));
2639        assert!(json.contains("\"name\":\"read_file\""));
2640    }
2641
2642    #[test]
2643    fn test_api_tool_message_serialization() {
2644        let message = ApiMessage {
2645            role: ApiRole::Tool,
2646            content: Some("File contents here".to_string()),
2647            reasoning_content: None,
2648            tool_calls: None,
2649            tool_call_id: Some("call_123".to_string()),
2650            prompt_cache_breakpoint: false,
2651        };
2652
2653        let json = serde_json::to_string(&message).unwrap();
2654        assert!(json.contains("\"role\":\"tool\""));
2655        assert!(json.contains("\"tool_call_id\":\"call_123\""));
2656        assert!(json.contains("\"content\":\"File contents here\""));
2657    }
2658
2659    #[test]
2660    fn test_api_tool_serialization() {
2661        let tool = ApiTool {
2662            r#type: "function".to_string(),
2663            function: ApiFunction {
2664                name: "test_tool".to_string(),
2665                description: "A test tool".to_string(),
2666                parameters: serde_json::json!({
2667                    "type": "object",
2668                    "properties": {
2669                        "arg": {"type": "string"}
2670                    }
2671                }),
2672                strict: Some(true),
2673            },
2674        };
2675
2676        let json = serde_json::to_string(&tool).unwrap();
2677        assert!(json.contains("\"type\":\"function\""));
2678        assert!(json.contains("\"name\":\"test_tool\""));
2679        assert!(json.contains("\"description\":\"A test tool\""));
2680        assert!(json.contains("\"parameters\""));
2681    }
2682
2683    // ===================
2684    // API Type Deserialization Tests
2685    // ===================
2686
2687    #[test]
2688    fn test_api_response_deserialization() {
2689        let json = r#"{
2690            "id": "chatcmpl-123",
2691            "choices": [
2692                {
2693                    "message": {
2694                        "content": "Hello!"
2695                    },
2696                    "finish_reason": "stop"
2697                }
2698            ],
2699            "model": "gpt-4o",
2700            "usage": {
2701                "prompt_tokens": 100,
2702                "completion_tokens": 50
2703            }
2704        }"#;
2705
2706        let response: ApiChatResponse = serde_json::from_str(json).unwrap();
2707        assert_eq!(response.id, "chatcmpl-123");
2708        assert_eq!(response.model, "gpt-4o");
2709        assert_eq!(response.usage.prompt_tokens, 100);
2710        assert_eq!(response.usage.completion_tokens, 50);
2711        assert_eq!(response.choices.len(), 1);
2712        assert_eq!(
2713            response.choices[0].message.content,
2714            Some("Hello!".to_string())
2715        );
2716    }
2717
2718    #[test]
2719    fn structured_refusal_is_visible_and_sets_refusal_stop_reason() -> anyhow::Result<()> {
2720        let body = serde_json::json!({
2721            "id": "chatcmpl-refusal",
2722            "choices": [{
2723                "message": {
2724                    "content": null,
2725                    "refusal": "I cannot help with that."
2726                },
2727                "finish_reason": "stop"
2728            }],
2729            "model": "gpt-5.6",
2730            "usage": {
2731                "prompt_tokens": 12,
2732                "completion_tokens": 6
2733            }
2734        });
2735        let bytes = serde_json::to_vec(&body)?;
2736        let outcome = decode_chat_response(StatusCode::OK, &bytes, None)?;
2737        let ChatOutcome::Success(response) = outcome else {
2738            anyhow::bail!("structured refusal did not decode as a successful provider response");
2739        };
2740
2741        assert_eq!(response.first_text(), Some("I cannot help with that."));
2742        assert_eq!(response.stop_reason, Some(StopReason::Refusal));
2743        Ok(())
2744    }
2745
2746    #[test]
2747    fn non_tool_terminal_suppresses_nonstream_tool_calls() -> anyhow::Result<()> {
2748        let body = serde_json::json!({
2749            "id": "chatcmpl-truncated-tool",
2750            "choices": [{
2751                "message": {
2752                    "content": "partial",
2753                    "tool_calls": [{
2754                        "id": "call_partial",
2755                        "type": "function",
2756                        "function": {
2757                            "name": "delete_record",
2758                            "arguments": "{\"id\":"
2759                        }
2760                    }]
2761                },
2762                "finish_reason": "length"
2763            }],
2764            "model": "gpt-5.6",
2765            "usage": {"prompt_tokens": 12, "completion_tokens": 6}
2766        });
2767        let bytes = serde_json::to_vec(&body)?;
2768        let ChatOutcome::Success(response) = decode_chat_response(StatusCode::OK, &bytes, None)?
2769        else {
2770            anyhow::bail!("truncated response did not decode as a successful provider response");
2771        };
2772
2773        assert_eq!(response.stop_reason, Some(StopReason::MaxTokens));
2774        assert_eq!(response.first_text(), Some("partial"));
2775        assert!(!response.has_tool_use());
2776        Ok(())
2777    }
2778
2779    #[test]
2780    fn test_api_response_with_tool_calls_deserialization() {
2781        let json = r#"{
2782            "id": "chatcmpl-456",
2783            "choices": [
2784                {
2785                    "message": {
2786                        "content": null,
2787                        "tool_calls": [
2788                            {
2789                                "id": "call_abc",
2790                                "type": "function",
2791                                "function": {
2792                                    "name": "read_file",
2793                                    "arguments": "{\"path\": \"test.txt\"}"
2794                                }
2795                            }
2796                        ]
2797                    },
2798                    "finish_reason": "tool_calls"
2799                }
2800            ],
2801            "model": "gpt-4o",
2802            "usage": {
2803                "prompt_tokens": 150,
2804                "completion_tokens": 30
2805            }
2806        }"#;
2807
2808        let response: ApiChatResponse = serde_json::from_str(json).unwrap();
2809        let tool_calls = response.choices[0].message.tool_calls.as_ref().unwrap();
2810        assert_eq!(tool_calls.len(), 1);
2811        assert_eq!(tool_calls[0].id, "call_abc");
2812        assert_eq!(tool_calls[0].function.name, "read_file");
2813    }
2814
2815    #[test]
2816    fn test_api_response_with_unknown_finish_reason_deserialization() {
2817        let json = r#"{
2818            "id": "chatcmpl-789",
2819            "choices": [
2820                {
2821                    "message": {
2822                        "content": "ok"
2823                    },
2824                    "finish_reason": "vendor_custom_reason"
2825                }
2826            ],
2827            "model": "glm-5",
2828            "usage": {
2829                "prompt_tokens": 10,
2830                "completion_tokens": 5
2831            }
2832        }"#;
2833
2834        let response: ApiChatResponse = serde_json::from_str(json).unwrap();
2835        assert_eq!(
2836            response.choices[0].finish_reason.as_deref(),
2837            Some("vendor_custom_reason")
2838        );
2839        assert_eq!(
2840            map_finish_reason(response.choices[0].finish_reason.as_deref().unwrap()),
2841            StopReason::Unknown
2842        );
2843    }
2844
2845    #[test]
2846    fn test_map_finish_reason_covers_vendor_specific_values() {
2847        assert_eq!(map_finish_reason("stop"), StopReason::EndTurn);
2848        assert_eq!(map_finish_reason("tool_calls"), StopReason::ToolUse);
2849        assert_eq!(map_finish_reason("length"), StopReason::MaxTokens);
2850        assert_eq!(map_finish_reason("content_filter"), StopReason::Refusal);
2851        assert_eq!(map_finish_reason("sensitive"), StopReason::Refusal);
2852        assert_eq!(map_finish_reason("refusal"), StopReason::Refusal);
2853        assert_eq!(map_finish_reason("network_error"), StopReason::Unknown);
2854        assert_eq!(map_finish_reason("some_new_reason"), StopReason::Unknown);
2855    }
2856
2857    // ===================
2858    // Message Conversion Tests
2859    // ===================
2860
2861    #[test]
2862    fn test_build_api_messages_with_system() {
2863        let request = ChatRequest {
2864            system: "You are helpful.".to_string(),
2865            messages: vec![agent_sdk_foundation::llm::Message::user("Hello")],
2866            tools: None,
2867            max_tokens: 1024,
2868            max_tokens_explicit: true,
2869            session_id: None,
2870            cached_content: None,
2871            thinking: None,
2872            tool_choice: None,
2873            response_format: None,
2874            cache: None,
2875        };
2876
2877        let api_messages = build_api_messages(&request);
2878        assert_eq!(api_messages.len(), 2);
2879        assert_eq!(api_messages[0].role, ApiRole::System);
2880        assert_eq!(
2881            api_messages[0].content,
2882            Some("You are helpful.".to_string())
2883        );
2884        assert_eq!(api_messages[1].role, ApiRole::User);
2885        assert_eq!(api_messages[1].content, Some("Hello".to_string()));
2886    }
2887
2888    #[test]
2889    fn test_build_api_messages_empty_system() {
2890        let request = ChatRequest {
2891            system: String::new(),
2892            messages: vec![agent_sdk_foundation::llm::Message::user("Hello")],
2893            tools: None,
2894            max_tokens: 1024,
2895            max_tokens_explicit: true,
2896            session_id: None,
2897            cached_content: None,
2898            thinking: None,
2899            tool_choice: None,
2900            response_format: None,
2901            cache: None,
2902        };
2903
2904        let api_messages = build_api_messages(&request);
2905        assert_eq!(api_messages.len(), 1);
2906        assert_eq!(api_messages[0].role, ApiRole::User);
2907    }
2908
2909    fn request_with_messages(messages: Vec<agent_sdk_foundation::llm::Message>) -> ChatRequest {
2910        ChatRequest {
2911            system: String::new(),
2912            messages,
2913            tools: None,
2914            max_tokens: 1024,
2915            max_tokens_explicit: true,
2916            session_id: None,
2917            cached_content: None,
2918            thinking: None,
2919            tool_choice: None,
2920            response_format: None,
2921            cache: None,
2922        }
2923    }
2924
2925    #[test]
2926    fn test_build_api_messages_echoes_assistant_reasoning_content_on_tool_call()
2927    -> anyhow::Result<()> {
2928        // DeepSeek V4 thinking-mode requires the prior assistant turn's
2929        // reasoning to be echoed back as `reasoning_content` ONLY on a turn
2930        // that also performed a tool call, or the API 400s.
2931        let request = request_with_messages(vec![
2932            agent_sdk_foundation::llm::Message::user("What is the weather?"),
2933            agent_sdk_foundation::llm::Message::assistant_with_content(vec![
2934                ContentBlock::Thinking {
2935                    thinking: "I should call the weather tool.".to_string(),
2936                    signature: None,
2937                },
2938                ContentBlock::ToolUse {
2939                    id: "call_1".to_string(),
2940                    name: "get_weather".to_string(),
2941                    input: serde_json::json!({"city": "Paris"}),
2942                    thought_signature: None,
2943                },
2944            ]),
2945        ]);
2946
2947        let api_messages = build_api_messages(&request);
2948        let assistant = api_messages
2949            .iter()
2950            .find(|m| m.role == ApiRole::Assistant)
2951            .context("assistant message present")?;
2952        assert!(assistant.tool_calls.is_some());
2953        assert_eq!(
2954            assistant.reasoning_content,
2955            Some("I should call the weather tool.".to_string())
2956        );
2957        Ok(())
2958    }
2959
2960    #[test]
2961    fn test_build_api_messages_reasoning_content_serializes_on_tool_call_turn() -> anyhow::Result<()>
2962    {
2963        let request = request_with_messages(vec![
2964            agent_sdk_foundation::llm::Message::assistant_with_content(vec![
2965                ContentBlock::Thinking {
2966                    thinking: "thinking out loud".to_string(),
2967                    signature: None,
2968                },
2969                ContentBlock::ToolUse {
2970                    id: "call_1".to_string(),
2971                    name: "do_thing".to_string(),
2972                    input: serde_json::json!({}),
2973                    thought_signature: None,
2974                },
2975            ]),
2976        ]);
2977
2978        let api_messages = build_api_messages(&request);
2979        let json = serde_json::to_string(&api_messages).context("serialize api messages")?;
2980        assert!(json.contains("\"reasoning_content\":\"thinking out loud\""));
2981        Ok(())
2982    }
2983
2984    #[test]
2985    fn test_build_api_messages_reasoning_only_turn_is_not_echoed() -> anyhow::Result<()> {
2986        // A reasoning-only assistant turn (no visible text, no tool call) must
2987        // NOT carry reasoning_content: legacy `deepseek-reasoner` 400s if
2988        // reasoning_content appears in input, and DeepSeek V4 thinking-mode only
2989        // needs it on tool-call turns. With no other payload the turn collapses
2990        // to nothing and is dropped entirely.
2991        let request = request_with_messages(vec![
2992            agent_sdk_foundation::llm::Message::assistant_with_content(vec![
2993                ContentBlock::Thinking {
2994                    thinking: "pondering".to_string(),
2995                    signature: None,
2996                },
2997            ]),
2998        ]);
2999
3000        let api_messages = build_api_messages(&request);
3001        let json = serde_json::to_string(&api_messages).context("serialize api messages")?;
3002        assert!(!json.contains("reasoning_content"));
3003        assert!(api_messages.is_empty());
3004        Ok(())
3005    }
3006
3007    #[test]
3008    fn test_build_api_messages_reasoning_with_text_no_tool_call_is_not_echoed() -> anyhow::Result<()>
3009    {
3010        // An assistant turn carrying reasoning + visible text but NO tool call
3011        // is emitted for its text, but its reasoning is NOT echoed back.
3012        let request = request_with_messages(vec![
3013            agent_sdk_foundation::llm::Message::user("What is 2+2?"),
3014            agent_sdk_foundation::llm::Message::assistant_with_content(vec![
3015                ContentBlock::Thinking {
3016                    thinking: "Let me add 2 and 2.".to_string(),
3017                    signature: None,
3018                },
3019                ContentBlock::Text {
3020                    text: "4".to_string(),
3021                },
3022            ]),
3023            agent_sdk_foundation::llm::Message::user("And 3+3?"),
3024        ]);
3025
3026        let api_messages = build_api_messages(&request);
3027        let json = serde_json::to_string(&api_messages).context("serialize api messages")?;
3028        assert!(!json.contains("reasoning_content"));
3029        let assistant = api_messages
3030            .iter()
3031            .find(|m| m.role == ApiRole::Assistant)
3032            .context("assistant message present")?;
3033        assert_eq!(assistant.content, Some("4".to_string()));
3034        assert_eq!(assistant.reasoning_content, None);
3035        Ok(())
3036    }
3037
3038    #[test]
3039    fn test_build_api_messages_normal_path_has_no_reasoning_content() -> anyhow::Result<()> {
3040        // Normal path unchanged: an assistant turn with no Thinking block must
3041        // not attach reasoning_content.
3042        let request = request_with_messages(vec![
3043            agent_sdk_foundation::llm::Message::user("hi"),
3044            agent_sdk_foundation::llm::Message::assistant_with_content(vec![ContentBlock::Text {
3045                text: "hello".to_string(),
3046            }]),
3047        ]);
3048
3049        let api_messages = build_api_messages(&request);
3050        let json = serde_json::to_string(&api_messages).context("serialize api messages")?;
3051        assert!(!json.contains("reasoning_content"));
3052        let assistant = api_messages
3053            .iter()
3054            .find(|m| m.role == ApiRole::Assistant)
3055            .context("assistant message present")?;
3056        assert_eq!(assistant.reasoning_content, None);
3057        Ok(())
3058    }
3059
3060    #[test]
3061    fn test_build_api_messages_does_not_attach_reasoning_to_user_blocks() {
3062        // A user turn carrying a Thinking block (unusual, but possible) must not
3063        // be turned into a reasoning_content echo.
3064        let request =
3065            request_with_messages(vec![agent_sdk_foundation::llm::Message::user_with_content(
3066                vec![
3067                    ContentBlock::Thinking {
3068                        thinking: "user-side thinking".to_string(),
3069                        signature: None,
3070                    },
3071                    ContentBlock::Text {
3072                        text: "question".to_string(),
3073                    },
3074                ],
3075            )]);
3076
3077        let api_messages = build_api_messages(&request);
3078        assert_eq!(api_messages.len(), 1);
3079        assert_eq!(api_messages[0].role, ApiRole::User);
3080        assert_eq!(api_messages[0].reasoning_content, None);
3081    }
3082
3083    #[test]
3084    fn test_convert_tool() {
3085        let tool = agent_sdk_foundation::llm::Tool {
3086            name: "test_tool".to_string(),
3087            description: "A test tool".to_string(),
3088            input_schema: serde_json::json!({
3089                "type": "object",
3090                "properties": {"value": {"type": "string"}}
3091            }),
3092            display_name: "Test Tool".to_string(),
3093            tier: agent_sdk_foundation::ToolTier::Observe,
3094        };
3095
3096        let compatible_tool = convert_tool(tool.clone(), false);
3097        assert!(compatible_tool.function.strict.is_none());
3098        assert_eq!(
3099            compatible_tool.function.parameters,
3100            serde_json::json!({
3101                "type": "object",
3102                "properties": {"value": {"type": "string"}}
3103            })
3104        );
3105
3106        let api_tool = convert_tool(tool, true);
3107        assert_eq!(api_tool.r#type, "function");
3108        assert_eq!(api_tool.function.name, "test_tool");
3109        assert_eq!(api_tool.function.description, "A test tool");
3110        assert_eq!(api_tool.function.strict, Some(true));
3111        assert_eq!(api_tool.function.parameters["additionalProperties"], false);
3112    }
3113
3114    #[test]
3115    fn test_build_content_blocks_text_only() {
3116        let message = ApiResponseMessage {
3117            content: Some("Hello!".to_string()),
3118            refusal: None,
3119            tool_calls: None,
3120            reasoning_content: None,
3121            reasoning: None,
3122        };
3123
3124        let blocks = build_content_blocks(&message);
3125        assert_eq!(blocks.len(), 1);
3126        assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Hello!"));
3127    }
3128
3129    #[test]
3130    fn test_build_content_blocks_with_tool_calls() {
3131        let message = ApiResponseMessage {
3132            content: Some("Let me help.".to_string()),
3133            refusal: None,
3134            tool_calls: Some(vec![ApiResponseToolCall {
3135                id: "call_123".to_string(),
3136                function: ApiResponseFunctionCall {
3137                    name: "read_file".to_string(),
3138                    arguments: "{\"path\": \"test.txt\"}".to_string(),
3139                },
3140            }]),
3141            reasoning_content: None,
3142            reasoning: None,
3143        };
3144
3145        let blocks = build_content_blocks(&message);
3146        assert_eq!(blocks.len(), 2);
3147        assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Let me help."));
3148        assert!(
3149            matches!(&blocks[1], ContentBlock::ToolUse { id, name, .. } if id == "call_123" && name == "read_file")
3150        );
3151    }
3152
3153    #[test]
3154    fn test_build_content_blocks_falls_back_to_reasoning_content_when_content_empty() {
3155        // DeepSeek-style: answer / usable output arrives in reasoning_content
3156        // while content is null. Without the fallback this dropped all output.
3157        let message = ApiResponseMessage {
3158            content: None,
3159            refusal: None,
3160            tool_calls: None,
3161            reasoning_content: Some("The answer is 42.".to_string()),
3162            reasoning: None,
3163        };
3164
3165        let blocks = build_content_blocks(&message);
3166        assert_eq!(blocks.len(), 1);
3167        assert!(
3168            matches!(&blocks[0], ContentBlock::Thinking { thinking, signature } if thinking == "The answer is 42." && signature.is_none())
3169        );
3170    }
3171
3172    #[test]
3173    fn test_build_content_blocks_falls_back_to_reasoning_field() {
3174        // Some OpenRouter upstreams normalize reasoning under `reasoning`.
3175        let message = ApiResponseMessage {
3176            content: Some(String::new()),
3177            refusal: None,
3178            tool_calls: None,
3179            reasoning_content: None,
3180            reasoning: Some("Considering options...".to_string()),
3181        };
3182
3183        let blocks = build_content_blocks(&message);
3184        assert_eq!(blocks.len(), 1);
3185        assert!(
3186            matches!(&blocks[0], ContentBlock::Thinking { thinking, .. } if thinking == "Considering options...")
3187        );
3188    }
3189
3190    #[test]
3191    fn test_build_content_blocks_prefers_reasoning_content_over_reasoning() {
3192        let message = ApiResponseMessage {
3193            content: None,
3194            refusal: None,
3195            tool_calls: None,
3196            reasoning_content: Some("primary".to_string()),
3197            reasoning: Some("secondary".to_string()),
3198        };
3199
3200        let blocks = build_content_blocks(&message);
3201        assert_eq!(blocks.len(), 1);
3202        assert!(
3203            matches!(&blocks[0], ContentBlock::Thinking { thinking, .. } if thinking == "primary")
3204        );
3205    }
3206
3207    #[test]
3208    fn test_build_content_blocks_does_not_add_reasoning_when_content_present() {
3209        // The normal content-present case must be unchanged: reasoning is NOT
3210        // surfaced as a Thinking block when there is usable text content.
3211        let message = ApiResponseMessage {
3212            content: Some("Final answer.".to_string()),
3213            refusal: None,
3214            tool_calls: None,
3215            reasoning_content: Some("internal chain of thought".to_string()),
3216            reasoning: None,
3217        };
3218
3219        let blocks = build_content_blocks(&message);
3220        assert_eq!(blocks.len(), 1);
3221        assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Final answer."));
3222    }
3223
3224    #[test]
3225    fn test_build_content_blocks_reasoning_fallback_with_tool_calls() {
3226        // Empty content + reasoning + a tool call: surface the reasoning AND the
3227        // tool call (reasoning model under tight max_tokens that still tool-called).
3228        let message = ApiResponseMessage {
3229            content: None,
3230            refusal: None,
3231            tool_calls: Some(vec![ApiResponseToolCall {
3232                id: "call_1".to_string(),
3233                function: ApiResponseFunctionCall {
3234                    name: "search".to_string(),
3235                    arguments: "{}".to_string(),
3236                },
3237            }]),
3238            reasoning_content: Some("I should search.".to_string()),
3239            reasoning: None,
3240        };
3241
3242        let blocks = build_content_blocks(&message);
3243        assert_eq!(blocks.len(), 2);
3244        assert!(
3245            matches!(&blocks[0], ContentBlock::Thinking { thinking, .. } if thinking == "I should search.")
3246        );
3247        assert!(matches!(&blocks[1], ContentBlock::ToolUse { name, .. } if name == "search"));
3248    }
3249
3250    #[test]
3251    fn test_build_content_blocks_empty_message_yields_no_blocks() {
3252        // Genuine truncation with no reasoning text: still produce nothing
3253        // (behavior unchanged for the empty case).
3254        let message = ApiResponseMessage {
3255            content: None,
3256            refusal: None,
3257            tool_calls: None,
3258            reasoning_content: None,
3259            reasoning: None,
3260        };
3261
3262        let blocks = build_content_blocks(&message);
3263        assert!(blocks.is_empty());
3264    }
3265
3266    #[test]
3267    fn test_api_response_message_deserializes_reasoning_content() {
3268        let json = r#"{
3269            "content": null,
3270            "reasoning_content": "step by step"
3271        }"#;
3272
3273        let message: ApiResponseMessage = serde_json::from_str(json).unwrap();
3274        assert_eq!(reasoning_text(&message), Some("step by step"));
3275        assert!(message.content.is_none());
3276    }
3277
3278    // ===================
3279    // SSE Streaming Type Tests
3280    // ===================
3281
3282    #[test]
3283    fn test_sse_chunk_text_delta_deserialization() {
3284        let json = r#"{
3285            "choices": [{
3286                "delta": {
3287                    "content": "Hello"
3288                },
3289                "finish_reason": null
3290            }]
3291        }"#;
3292
3293        let chunk: SseChunk = serde_json::from_str(json).unwrap();
3294        assert_eq!(chunk.choices.len(), 1);
3295        assert_eq!(chunk.choices[0].delta.content, Some("Hello".to_string()));
3296        assert!(chunk.choices[0].finish_reason.is_none());
3297    }
3298
3299    #[test]
3300    fn test_sse_chunk_tool_call_delta_deserialization() {
3301        let json = r#"{
3302            "choices": [{
3303                "delta": {
3304                    "tool_calls": [{
3305                        "index": 0,
3306                        "id": "call_abc",
3307                        "function": {
3308                            "name": "read_file",
3309                            "arguments": ""
3310                        }
3311                    }]
3312                },
3313                "finish_reason": null
3314            }]
3315        }"#;
3316
3317        let chunk: SseChunk = serde_json::from_str(json).unwrap();
3318        let tool_calls = chunk.choices[0].delta.tool_calls.as_ref().unwrap();
3319        assert_eq!(tool_calls.len(), 1);
3320        assert_eq!(tool_calls[0].index, 0);
3321        assert_eq!(tool_calls[0].id, Some("call_abc".to_string()));
3322        assert_eq!(
3323            tool_calls[0].function.as_ref().unwrap().name,
3324            Some("read_file".to_string())
3325        );
3326    }
3327
3328    #[test]
3329    fn test_sse_chunk_tool_call_arguments_delta_deserialization() {
3330        let json = r#"{
3331            "choices": [{
3332                "delta": {
3333                    "tool_calls": [{
3334                        "index": 0,
3335                        "function": {
3336                            "arguments": "{\"path\":"
3337                        }
3338                    }]
3339                },
3340                "finish_reason": null
3341            }]
3342        }"#;
3343
3344        let chunk: SseChunk = serde_json::from_str(json).unwrap();
3345        let tool_calls = chunk.choices[0].delta.tool_calls.as_ref().unwrap();
3346        assert_eq!(tool_calls[0].id, None);
3347        assert_eq!(
3348            tool_calls[0].function.as_ref().unwrap().arguments,
3349            Some("{\"path\":".to_string())
3350        );
3351    }
3352
3353    #[test]
3354    fn test_sse_chunk_with_finish_reason_deserialization() {
3355        let json = r#"{
3356            "choices": [{
3357                "delta": {},
3358                "finish_reason": "stop"
3359            }]
3360        }"#;
3361
3362        let chunk: SseChunk = serde_json::from_str(json).unwrap();
3363        assert_eq!(chunk.choices[0].finish_reason.as_deref(), Some("stop"));
3364    }
3365
3366    #[test]
3367    fn test_sse_chunk_with_usage_deserialization() {
3368        let json = r#"{
3369            "choices": [{
3370                "delta": {},
3371                "finish_reason": "stop"
3372            }],
3373            "usage": {
3374                "prompt_tokens": 100,
3375                "completion_tokens": 50
3376            }
3377        }"#;
3378
3379        let chunk: SseChunk = serde_json::from_str(json).unwrap();
3380        let usage = chunk.usage.unwrap();
3381        assert_eq!(usage.prompt_tokens, 100);
3382        assert_eq!(usage.completion_tokens, 50);
3383    }
3384
3385    #[test]
3386    fn test_sse_chunk_with_float_usage_deserialization() {
3387        let json = r#"{
3388            "choices": [{
3389                "delta": {},
3390                "finish_reason": "stop"
3391            }],
3392            "usage": {
3393                "prompt_tokens": 100.0,
3394                "completion_tokens": 50.0
3395            }
3396        }"#;
3397
3398        let chunk: SseChunk = serde_json::from_str(json).unwrap();
3399        let usage = chunk.usage.unwrap();
3400        assert_eq!(usage.prompt_tokens, 100);
3401        assert_eq!(usage.completion_tokens, 50);
3402    }
3403
3404    #[test]
3405    fn test_api_usage_deserializes_integer_compatible_numbers() {
3406        let json = r#"{
3407            "prompt_tokens": 42.0,
3408            "completion_tokens": 7
3409        }"#;
3410
3411        let usage: ApiUsage = serde_json::from_str(json).unwrap();
3412        assert_eq!(usage.prompt_tokens, 42);
3413        assert_eq!(usage.completion_tokens, 7);
3414    }
3415
3416    #[test]
3417    fn test_api_usage_deserializes_cache_read_and_write_tokens() -> anyhow::Result<()> {
3418        let json = r#"{
3419            "prompt_tokens": 42,
3420            "completion_tokens": 7,
3421            "prompt_tokens_details": {
3422                "cached_tokens": 10,
3423                "cache_write_tokens": 6
3424            }
3425        }"#;
3426
3427        let usage: ApiUsage = serde_json::from_str(json)?;
3428        assert_eq!(usage.prompt_tokens, 42);
3429        assert_eq!(usage.completion_tokens, 7);
3430        let details = usage
3431            .prompt_tokens_details
3432            .context("prompt token details missing")?;
3433        assert_eq!(details.cached_tokens, 10);
3434        assert_eq!(details.cache_write_tokens, 6);
3435        Ok(())
3436    }
3437
3438    #[test]
3439    fn test_process_sse_data_maps_cache_read_and_write_usage() {
3440        let results = process_sse_data(
3441            r#"{
3442                "choices": [],
3443                "usage": {
3444                    "prompt_tokens": 42,
3445                    "completion_tokens": 7,
3446                    "prompt_tokens_details": {
3447                        "cached_tokens": 10,
3448                        "cache_write_tokens": 6
3449                    }
3450                }
3451            }"#,
3452        );
3453
3454        assert!(matches!(
3455            results.as_slice(),
3456            [SseProcessResult::Usage(Usage {
3457                input_tokens: 42,
3458                output_tokens: 7,
3459                cached_input_tokens: 10,
3460                cache_creation_input_tokens: 6,
3461            })]
3462        ));
3463    }
3464
3465    #[test]
3466    fn test_sse_delta_deserializes_reasoning_fields() -> anyhow::Result<()> {
3467        // The streaming delta struct must accept DeepSeek `reasoning_content`
3468        // and OpenRouter-normalized `reasoning` so reasoning tokens are not
3469        // dropped on deserialization.
3470        let chunk: SseChunk = serde_json::from_str(
3471            r#"{
3472                "choices": [{
3473                    "delta": {
3474                        "reasoning_content": "step one"
3475                    },
3476                    "finish_reason": null
3477                }]
3478            }"#,
3479        )
3480        .context("deserialize sse chunk")?;
3481        assert_eq!(
3482            chunk.choices[0].delta.reasoning_content,
3483            Some("step one".to_string())
3484        );
3485        assert!(chunk.choices[0].delta.content.is_none());
3486        Ok(())
3487    }
3488
3489    #[test]
3490    fn test_process_sse_data_emits_thinking_delta_from_reasoning_content() {
3491        // Reasoning-model fallback under streaming: a delta whose visible
3492        // `content` is absent but whose `reasoning_content` carries tokens must
3493        // surface as a ThinkingDelta, mirroring the non-streaming fallback so the
3494        // output is not silently dropped.
3495        let results = process_sse_data(
3496            r#"{
3497                "choices": [{
3498                    "delta": { "reasoning_content": "thinking..." },
3499                    "finish_reason": null
3500                }]
3501            }"#,
3502        );
3503
3504        assert!(matches!(
3505            results.as_slice(),
3506            [SseProcessResult::ThinkingDelta(text)] if text == "thinking..."
3507        ));
3508    }
3509
3510    #[test]
3511    fn test_process_sse_data_emits_thinking_delta_from_reasoning_field() {
3512        // OpenRouter-normalized `reasoning` field is an equivalent fallback.
3513        let results = process_sse_data(
3514            r#"{
3515                "choices": [{
3516                    "delta": { "reasoning": "pondering" },
3517                    "finish_reason": null
3518                }]
3519            }"#,
3520        );
3521
3522        assert!(matches!(
3523            results.as_slice(),
3524            [SseProcessResult::ThinkingDelta(text)] if text == "pondering"
3525        ));
3526    }
3527
3528    #[test]
3529    fn test_process_sse_data_prefers_text_content_over_reasoning() {
3530        // When visible `content` is present, it takes precedence and the
3531        // reasoning fallback does not fire (mirrors non-streaming behavior).
3532        let results = process_sse_data(
3533            r#"{
3534                "choices": [{
3535                    "delta": {
3536                        "content": "answer",
3537                        "reasoning_content": "ignored"
3538                    },
3539                    "finish_reason": null
3540                }]
3541            }"#,
3542        );
3543
3544        assert!(matches!(
3545            results.as_slice(),
3546            [SseProcessResult::TextDelta(text)] if text == "answer"
3547        ));
3548    }
3549
3550    #[test]
3551    fn test_process_sse_data_empty_content_falls_back_to_reasoning() {
3552        // An explicitly empty `content` string must still trigger the reasoning
3553        // fallback rather than emitting an empty TextDelta.
3554        let results = process_sse_data(
3555            r#"{
3556                "choices": [{
3557                    "delta": {
3558                        "content": "",
3559                        "reasoning_content": "fallback"
3560                    },
3561                    "finish_reason": null
3562                }]
3563            }"#,
3564        );
3565
3566        assert!(matches!(
3567            results.as_slice(),
3568            [SseProcessResult::ThinkingDelta(text)] if text == "fallback"
3569        ));
3570    }
3571
3572    #[test]
3573    fn streamed_refusal_is_visible_and_preserves_refusal_stop_reason() -> anyhow::Result<()> {
3574        let mut tool_calls = HashMap::new();
3575        let mut usage = None;
3576        let mut stop_reason = None;
3577        let chunk = step_completion_stream(
3578            r#"{"choices":[{"delta":{"refusal":"Cannot comply."},"finish_reason":"stop"}]}"#,
3579            &mut tool_calls,
3580            &mut usage,
3581            &mut stop_reason,
3582        );
3583
3584        assert!(matches!(
3585            chunk.immediate.as_slice(),
3586            [StreamDelta::TextDelta { delta, .. }] if delta == "Cannot comply."
3587        ));
3588        assert_eq!(stop_reason, Some(StopReason::Refusal));
3589
3590        let done = step_completion_stream("[DONE]", &mut tool_calls, &mut usage, &mut stop_reason)
3591            .terminal
3592            .context("[DONE] did not finalize refusal stream")?;
3593        assert!(done.iter().any(|delta| matches!(
3594            delta,
3595            StreamDelta::Done {
3596                stop_reason: Some(StopReason::Refusal)
3597            }
3598        )));
3599        Ok(())
3600    }
3601
3602    #[test]
3603    fn test_api_usage_rejects_fractional_numbers() {
3604        let json = r#"{
3605            "prompt_tokens": 42.5,
3606            "completion_tokens": 7
3607        }"#;
3608
3609        let usage: std::result::Result<ApiUsage, _> = serde_json::from_str(json);
3610        assert!(usage.is_err());
3611    }
3612
3613    #[test]
3614    fn test_use_max_tokens_alias_for_vendor_urls() {
3615        assert!(!use_max_tokens_alias(DEFAULT_BASE_URL));
3616        assert!(use_max_tokens_alias(BASE_URL_KIMI));
3617        assert!(use_max_tokens_alias(BASE_URL_ZAI));
3618        assert!(use_max_tokens_alias(BASE_URL_MINIMAX));
3619    }
3620
3621    #[test]
3622    fn test_requires_responses_api_for_codex_models() {
3623        assert!(requires_responses_api(MODEL_GPT52_CODEX));
3624        assert!(requires_responses_api(MODEL_GPT52_PRO));
3625        assert!(requires_responses_api(MODEL_GPT53_CODEX));
3626        assert!(!requires_responses_api(MODEL_GPT54));
3627        assert!(!requires_responses_api(MODEL_GPT56));
3628        assert!(!requires_responses_api(MODEL_GPT56_SOL));
3629        assert!(!requires_responses_api(MODEL_GPT56_TERRA));
3630        assert!(!requires_responses_api(MODEL_GPT56_LUNA));
3631    }
3632
3633    #[test]
3634    fn test_should_use_responses_api_for_official_agentic_requests() {
3635        let request = ChatRequest {
3636            system: String::new(),
3637            messages: vec![agent_sdk_foundation::llm::Message::user("Hello")],
3638            tools: Some(vec![agent_sdk_foundation::llm::Tool {
3639                name: "read_file".to_string(),
3640                description: "Read a file".to_string(),
3641                input_schema: serde_json::json!({"type": "object"}),
3642                display_name: "Read File".to_string(),
3643                tier: agent_sdk_foundation::ToolTier::Observe,
3644            }]),
3645            max_tokens: 1024,
3646            max_tokens_explicit: true,
3647            session_id: Some("thread-1".to_string()),
3648            cached_content: None,
3649            thinking: None,
3650            tool_choice: None,
3651            response_format: None,
3652            cache: None,
3653        };
3654
3655        for model in [
3656            MODEL_GPT54,
3657            MODEL_GPT56,
3658            MODEL_GPT56_SOL,
3659            MODEL_GPT56_TERRA,
3660            MODEL_GPT56_LUNA,
3661        ] {
3662            assert!(should_use_responses_api(
3663                DEFAULT_BASE_URL,
3664                model,
3665                &request,
3666                None
3667            ));
3668        }
3669        assert!(!should_use_responses_api(
3670            BASE_URL_KIMI,
3671            MODEL_GPT54,
3672            &request,
3673            None,
3674        ));
3675    }
3676
3677    #[test]
3678    fn official_gpt56_auto_routes_but_custom_base_url_does_not() {
3679        let request = ChatRequest::new(String::new(), vec![]);
3680        assert!(should_use_responses_api(
3681            DEFAULT_BASE_URL,
3682            MODEL_GPT56,
3683            &request,
3684            None,
3685        ));
3686        assert!(!should_use_responses_api(
3687            "https://gateway.example/v1",
3688            MODEL_GPT56,
3689            &request,
3690            None,
3691        ));
3692    }
3693
3694    #[test]
3695    fn response_only_reasoning_controls_trigger_reroute() {
3696        let request = ChatRequest::new(String::new(), vec![]);
3697        let reasoning = OpenAIReasoningConfig::new()
3698            .with_mode(super::super::openai_reasoning::OpenAIReasoningMode::Pro);
3699        assert!(should_use_responses_api(
3700            "https://gateway.example/v1",
3701            MODEL_GPT56,
3702            &request,
3703            Some(&reasoning),
3704        ));
3705
3706        let forced_chat = reasoning.with_api_surface(OpenAIApiSurface::ChatCompletions);
3707        assert!(!should_use_responses_api(
3708            "https://gateway.example/v1",
3709            MODEL_GPT56,
3710            &request,
3711            Some(&forced_chat),
3712        ));
3713        assert!(OpenAIProvider::validate_chat_reasoning_controls(Some(&forced_chat)).is_err());
3714
3715        let codex = OpenAIProvider::gpt53_codex("test-key".to_owned()).with_reasoning(
3716            OpenAIReasoningConfig::new().with_api_surface(OpenAIApiSurface::ChatCompletions),
3717        );
3718        assert!(codex.validate_requested_api_surface().is_err());
3719    }
3720
3721    #[test]
3722    fn openai_responses_history_reroutes_auto_and_forced_chat_rejects() {
3723        let request = ChatRequest::new(
3724            String::new(),
3725            vec![agent_sdk_foundation::llm::Message::assistant_with_content(
3726                vec![ContentBlock::OpaqueReasoning {
3727                    provider: OPENAI_RESPONSES_REASONING_PROVIDER.to_owned(),
3728                    data: serde_json::json!({
3729                        "type": "reasoning",
3730                        "id": "rs_1",
3731                        "encrypted_content": "ciphertext"
3732                    }),
3733                }],
3734            )],
3735        );
3736
3737        assert!(should_use_responses_api(
3738            "https://gateway.example/v1",
3739            MODEL_GPT54,
3740            &request,
3741            None,
3742        ));
3743
3744        let forced_chat =
3745            OpenAIReasoningConfig::new().with_api_surface(OpenAIApiSurface::ChatCompletions);
3746        assert!(!should_use_responses_api(
3747            "https://gateway.example/v1",
3748            MODEL_GPT54,
3749            &request,
3750            Some(&forced_chat),
3751        ));
3752        assert!(OpenAIProvider::validate_chat_opaque_reasoning(&request).is_err());
3753    }
3754
3755    #[test]
3756    fn exact_chat_tool_choice_maps_and_validates() -> anyhow::Result<()> {
3757        let tools = vec![function_tool("read_file")];
3758
3759        let required = OpenAIReasoningConfig::new().with_tool_choice(OpenAIToolChoice::Required);
3760        let choice = OpenAIProvider::resolve_chat_tool_choice(None, Some(&required), Some(&tools))?
3761            .context("required tool choice was omitted")?;
3762        assert_eq!(serde_json::to_value(choice)?, serde_json::json!("required"));
3763
3764        let forced = OpenAIReasoningConfig::new()
3765            .with_tool_choice(OpenAIToolChoice::Function("read_file".to_owned()));
3766        let choice = OpenAIProvider::resolve_chat_tool_choice(None, Some(&forced), Some(&tools))?
3767            .context("forced tool choice was omitted")?;
3768        assert_eq!(
3769            serde_json::to_value(choice)?,
3770            serde_json::json!({"type": "function", "function": {"name": "read_file"}})
3771        );
3772
3773        let allowed =
3774            OpenAIReasoningConfig::new().with_tool_choice(OpenAIToolChoice::AllowedTools {
3775                mode: OpenAIAllowedToolsMode::Required,
3776                tools: vec!["read_file".to_owned()],
3777            });
3778        let choice = OpenAIProvider::resolve_chat_tool_choice(None, Some(&allowed), Some(&tools))?
3779            .context("allowed-tools choice was omitted")?;
3780        assert_eq!(
3781            serde_json::to_value(choice)?,
3782            serde_json::json!({
3783                "type": "allowed_tools",
3784                "allowed_tools": {
3785                    "mode": "required",
3786                    "tools": [
3787                        {"type": "function", "function": {"name": "read_file"}}
3788                    ]
3789                }
3790            })
3791        );
3792
3793        let missing = OpenAIReasoningConfig::new()
3794            .with_tool_choice(OpenAIToolChoice::Function("missing".to_owned()));
3795        assert!(
3796            OpenAIProvider::resolve_chat_tool_choice(None, Some(&missing), Some(&tools)).is_err()
3797        );
3798        assert!(OpenAIProvider::resolve_chat_tool_choice(None, Some(&required), None).is_err());
3799        Ok(())
3800    }
3801
3802    #[test]
3803    fn generic_tool_choice_overrides_provider_exact_choice() -> anyhow::Result<()> {
3804        let tools = vec![function_tool("read_file")];
3805        let request_choice = ToolChoice::Tool("read_file".to_owned());
3806        let provider_choice =
3807            OpenAIReasoningConfig::new().with_tool_choice(OpenAIToolChoice::AllowedTools {
3808                mode: super::super::openai_reasoning::OpenAIAllowedToolsMode::Required,
3809                tools: vec!["missing".to_owned()],
3810            });
3811        let request = ChatRequest::new(String::new(), vec![])
3812            .with_tools(tools.clone())
3813            .with_tool_choice(request_choice.clone());
3814
3815        assert!(!should_use_responses_api(
3816            "https://gateway.example/v1",
3817            MODEL_GPT54,
3818            &request,
3819            Some(&provider_choice),
3820        ));
3821        let choice = OpenAIProvider::resolve_chat_tool_choice(
3822            Some(&request_choice),
3823            Some(&provider_choice),
3824            Some(&tools),
3825        )?
3826        .context("generic tool choice was omitted")?;
3827        assert_eq!(
3828            serde_json::to_value(choice)?,
3829            serde_json::json!({"type": "function", "function": {"name": "read_file"}})
3830        );
3831        Ok(())
3832    }
3833
3834    #[test]
3835    fn generic_cache_control_overrides_exact_defaults_and_rejects_unmappable_ttl()
3836    -> anyhow::Result<()> {
3837        use agent_sdk_foundation::llm::{CacheConfig, CacheTtl};
3838
3839        let provider = OpenAIProvider::gpt56("test-key".to_owned());
3840        let exact = OpenAIReasoningConfig::new()
3841            .with_prompt_cache_mode(OpenAIPromptCacheMode::Explicit)
3842            .with_prompt_cache_ttl(OpenAIPromptCacheTtl::ThirtyMinutes);
3843
3844        let inherited_plan = provider.resolve_chat_prompt_cache_options(
3845            &ChatRequest::new(String::new(), vec![]),
3846            Some(&exact),
3847        )?;
3848        assert_eq!(inherited_plan.explicit_breakpoints, 0);
3849        let inherited = inherited_plan
3850            .options
3851            .context("exact prompt-cache options were omitted")?;
3852        assert_eq!(
3853            serde_json::to_value(inherited)?,
3854            serde_json::json!({"mode": "explicit", "ttl": "30m"})
3855        );
3856
3857        let enabled = ChatRequest::new(String::new(), vec![]).with_cache(CacheConfig::enabled());
3858        let enabled_plan = provider.resolve_chat_prompt_cache_options(&enabled, Some(&exact))?;
3859        assert_eq!(enabled_plan.explicit_breakpoints, 0);
3860        let enabled = enabled_plan
3861            .options
3862            .context("generic enabled cache options were omitted")?;
3863        assert_eq!(
3864            serde_json::to_value(enabled)?,
3865            serde_json::json!({"mode": "explicit", "ttl": "30m"})
3866        );
3867
3868        let disabled = ChatRequest::new(String::new(), vec![]).with_cache(CacheConfig::disabled());
3869        let disabled_plan = provider.resolve_chat_prompt_cache_options(&disabled, Some(&exact))?;
3870        assert_eq!(disabled_plan.explicit_breakpoints, 0);
3871        let disabled = disabled_plan
3872            .options
3873            .context("generic disabled cache options were omitted")?;
3874        assert_eq!(
3875            serde_json::to_value(disabled)?,
3876            serde_json::json!({"mode": "explicit"})
3877        );
3878
3879        let zero_breakpoints = ChatRequest::new(String::new(), vec![])
3880            .with_cache(CacheConfig::enabled().with_max_breakpoints(0));
3881        let zero_breakpoints_plan =
3882            provider.resolve_chat_prompt_cache_options(&zero_breakpoints, Some(&exact))?;
3883        assert_eq!(zero_breakpoints_plan.explicit_breakpoints, 0);
3884        let zero_breakpoints = zero_breakpoints_plan
3885            .options
3886            .context("zero-breakpoint cache options were omitted")?;
3887        assert_eq!(
3888            serde_json::to_value(zero_breakpoints)?,
3889            serde_json::json!({"mode": "explicit", "ttl": "30m"})
3890        );
3891
3892        let two_breakpoints = ChatRequest::new(String::new(), vec![])
3893            .with_cache(CacheConfig::enabled().with_max_breakpoints(2));
3894        let two_breakpoints =
3895            provider.resolve_chat_prompt_cache_options(&two_breakpoints, Some(&exact))?;
3896        assert_eq!(two_breakpoints.explicit_breakpoints, 2);
3897
3898        for ttl in [CacheTtl::FiveMinutes, CacheTtl::OneHour] {
3899            for cache in [
3900                CacheConfig::enabled().with_ttl(ttl),
3901                CacheConfig::disabled().with_ttl(ttl),
3902            ] {
3903                let request = ChatRequest::new(String::new(), vec![]).with_cache(cache);
3904                let error = provider
3905                    .resolve_chat_prompt_cache_options(&request, Some(&exact))
3906                    .err()
3907                    .context("shared cache TTL was silently accepted")?;
3908                assert!(error.to_string().contains("supports only a 30m TTL"));
3909            }
3910        }
3911        Ok(())
3912    }
3913
3914    #[test]
3915    fn chat_cache_breakpoints_prioritize_system_then_conversation_tail() -> anyhow::Result<()> {
3916        let request = ChatRequest::new(
3917            "system",
3918            vec![
3919                agent_sdk_foundation::llm::Message::user("one"),
3920                agent_sdk_foundation::llm::Message::assistant("two"),
3921                agent_sdk_foundation::llm::Message::user("three"),
3922                agent_sdk_foundation::llm::Message::assistant("four"),
3923                agent_sdk_foundation::llm::Message::user("five"),
3924            ],
3925        );
3926        let json = serde_json::to_value(build_api_messages_with_cache(&request, 9))?;
3927        let messages = json.as_array().context("messages must be an array")?;
3928        let marked = messages
3929            .iter()
3930            .filter(|message| message["content"].is_array())
3931            .count();
3932        assert_eq!(marked, 4);
3933        assert_eq!(messages[0]["content"][0]["text"], "system");
3934        assert_eq!(
3935            messages[0]["content"][0]["prompt_cache_breakpoint"]["mode"],
3936            "explicit"
3937        );
3938        assert_eq!(messages[1]["content"], "one");
3939        assert_eq!(messages[2]["content"], "two");
3940        assert_eq!(messages[5]["content"][0]["text"], "five");
3941        assert_eq!(
3942            messages[5]["content"][0]["prompt_cache_breakpoint"]["mode"],
3943            "explicit"
3944        );
3945
3946        let one_breakpoint = serde_json::to_value(build_api_messages_with_cache(&request, 1))?;
3947        let messages = one_breakpoint
3948            .as_array()
3949            .context("messages must be an array")?;
3950        assert!(messages[0]["content"].is_array());
3951        assert!(messages[5]["content"].is_string());
3952        Ok(())
3953    }
3954
3955    #[test]
3956    fn chat_strict_response_format_rejects_free_form_objects() -> anyhow::Result<()> {
3957        let format = agent_sdk_foundation::llm::ResponseFormat::new(
3958            "freeform",
3959            serde_json::json!({"type": "object"}),
3960        );
3961        assert!(
3962            OpenAIProvider::gpt54("test-key".to_owned())
3963                .validate_chat_response_format(Some(&format))
3964                .is_err()
3965        );
3966        assert!(
3967            OpenAIProvider::with_base_url("test-key", MODEL_GPT54, "https://gateway.example/v1",)
3968                .validate_chat_response_format(Some(&format))
3969                .is_ok()
3970        );
3971
3972        let structured = agent_sdk_foundation::llm::ResponseFormat::new(
3973            "structured",
3974            serde_json::json!({
3975                "type": "object",
3976                "properties": {"optional_name": {"type": "string"}}
3977            }),
3978        );
3979        let compatible = ApiResponseFormat::from_response_format(&structured, false);
3980        let compatible_json = serde_json::to_value(compatible)?;
3981        assert!(
3982            compatible_json["json_schema"]["schema"]
3983                .get("required")
3984                .is_none()
3985        );
3986
3987        let official = ApiResponseFormat::from_response_format(&structured, true);
3988        let official_json = serde_json::to_value(official)?;
3989        assert_eq!(
3990            official_json["json_schema"]["schema"]["required"],
3991            serde_json::json!(["optional_name"])
3992        );
3993        Ok(())
3994    }
3995
3996    #[test]
3997    fn forced_or_custom_chat_rejects_unserialized_attachments() {
3998        let request = ChatRequest::new(
3999            String::new(),
4000            vec![agent_sdk_foundation::llm::Message::user_with_content(vec![
4001                ContentBlock::Image {
4002                    source: agent_sdk_foundation::llm::ContentSource::new("image/png", "aGVsbG8="),
4003                },
4004            ])],
4005        );
4006        let forced_chat =
4007            OpenAIReasoningConfig::new().with_api_surface(OpenAIApiSurface::ChatCompletions);
4008
4009        assert!(!should_use_responses_api(
4010            DEFAULT_BASE_URL,
4011            MODEL_GPT54,
4012            &request,
4013            Some(&forced_chat),
4014        ));
4015        assert!(
4016            OpenAIProvider::gpt54("test-key".to_owned())
4017                .with_reasoning(forced_chat)
4018                .validate_chat_attachments(&request)
4019                .is_err()
4020        );
4021        assert!(
4022            OpenAIProvider::with_base_url("test-key", MODEL_GPT54, "https://gateway.example/v1",)
4023                .validate_chat_attachments(&request)
4024                .is_err()
4025        );
4026        assert!(should_use_responses_api(
4027            DEFAULT_BASE_URL,
4028            MODEL_GPT54,
4029            &request,
4030            None,
4031        ));
4032    }
4033
4034    #[test]
4035    fn legacy_budget_and_effort_mapping_remains_compatible() {
4036        assert_eq!(
4037            legacy_reasoning_effort(&ThinkingConfig::new(40_000)),
4038            Some(OpenAIReasoningEffort::XHigh)
4039        );
4040        assert_eq!(
4041            legacy_reasoning_effort(&ThinkingConfig::adaptive_with_effort(
4042                agent_sdk_foundation::llm::Effort::High,
4043            )),
4044            Some(OpenAIReasoningEffort::High)
4045        );
4046        assert_eq!(legacy_reasoning_effort(&ThinkingConfig::adaptive()), None);
4047    }
4048
4049    #[test]
4050    fn effective_max_tokens_uses_model_default_unless_explicit() {
4051        let provider = OpenAIProvider::gpt56("test-key".to_string());
4052        let implicit = ChatRequest::new(String::new(), vec![]);
4053        assert_eq!(provider.effective_max_tokens(&implicit), 128_000);
4054
4055        let explicit = implicit.with_max_tokens(8_192);
4056        assert_eq!(provider.effective_max_tokens(&explicit), 8_192);
4057    }
4058
4059    #[test]
4060    fn test_openai_rejects_adaptive_thinking() {
4061        let provider = OpenAIProvider::gpt54("test-key".to_string());
4062        let error = provider
4063            .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
4064            .unwrap_err();
4065        assert!(
4066            error
4067                .to_string()
4068                .contains("adaptive thinking is not supported")
4069        );
4070    }
4071
4072    #[test]
4073    fn test_openai_non_reasoning_models_reject_thinking() {
4074        let provider = OpenAIProvider::gpt4o("test-key".to_string());
4075        let error = provider
4076            .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
4077            .unwrap_err();
4078        assert!(error.to_string().contains("thinking is not supported"));
4079    }
4080
4081    #[test]
4082    fn test_request_serialization_openai_uses_max_completion_tokens_only() {
4083        let messages = vec![ApiMessage {
4084            role: ApiRole::User,
4085            content: Some("Hello".to_string()),
4086            reasoning_content: None,
4087            tool_calls: None,
4088            tool_call_id: None,
4089            prompt_cache_breakpoint: false,
4090        }];
4091
4092        let request = ApiChatRequest {
4093            model: "gpt-4o",
4094            messages: &messages,
4095            max_completion_tokens: Some(1024),
4096            max_tokens: None,
4097            tools: None,
4098            tool_choice: None,
4099            reasoning: None,
4100            response_format: None,
4101            verbosity: None,
4102            prompt_cache_options: None,
4103            store: Some(false),
4104            parallel_tool_calls: None,
4105            safety_identifier: None,
4106            prompt_cache_key: None,
4107        };
4108
4109        let json = serde_json::to_string(&request).unwrap();
4110        assert!(json.contains("\"max_completion_tokens\":1024"));
4111        assert!(!json.contains("\"max_tokens\""));
4112        assert!(json.contains("\"store\":false"));
4113    }
4114
4115    #[test]
4116    fn custom_chat_omits_first_party_storage_and_cache_defaults() {
4117        let explicit_store = OpenAIReasoningConfig::new().with_store(true);
4118
4119        assert_eq!(chat_store(DEFAULT_BASE_URL, None), Some(false));
4120        assert_eq!(
4121            chat_store("https://gateway.example/v1", Some(&explicit_store)),
4122            Some(true)
4123        );
4124        assert_eq!(chat_store("https://gateway.example/v1", None), None);
4125        assert_eq!(
4126            chat_prompt_cache_key(DEFAULT_BASE_URL, Some("thread-42")),
4127            Some("thread-42")
4128        );
4129        assert_eq!(
4130            chat_prompt_cache_key("https://gateway.example/v1", Some("thread-42")),
4131            None
4132        );
4133    }
4134
4135    #[test]
4136    fn official_openai_url_detection_uses_the_exact_parsed_host() {
4137        assert!(is_official_openai_base_url(DEFAULT_BASE_URL));
4138        assert!(is_official_openai_base_url(
4139            "https://api.openai.com/custom-prefix/v1"
4140        ));
4141        assert!(!is_official_openai_base_url(
4142            "https://api.openai.com.attacker.example/v1"
4143        ));
4144        assert!(!is_official_openai_base_url(
4145            "https://gateway.example/v1?upstream=api.openai.com"
4146        ));
4147        assert!(!is_official_openai_base_url("not a URL api.openai.com"));
4148    }
4149
4150    #[test]
4151    fn test_request_serializes_openai_application_controls() -> anyhow::Result<()> {
4152        let messages = vec![ApiMessage {
4153            role: ApiRole::User,
4154            content: Some("Hello".to_owned()),
4155            reasoning_content: None,
4156            tool_calls: None,
4157            tool_call_id: None,
4158            prompt_cache_breakpoint: false,
4159        }];
4160        let request = ApiChatRequest {
4161            model: MODEL_GPT56,
4162            messages: &messages,
4163            max_completion_tokens: Some(1024),
4164            max_tokens: None,
4165            tools: None,
4166            tool_choice: None,
4167            reasoning: None,
4168            response_format: None,
4169            verbosity: None,
4170            prompt_cache_options: None,
4171            store: Some(true),
4172            parallel_tool_calls: Some(false),
4173            safety_identifier: Some("safety-user-42"),
4174            prompt_cache_key: Some("thread-42"),
4175        };
4176
4177        let json = serde_json::to_value(request)?;
4178        assert_eq!(json["store"], true);
4179        assert_eq!(json["parallel_tool_calls"], false);
4180        assert_eq!(json["safety_identifier"], "safety-user-42");
4181        assert_eq!(json["prompt_cache_key"], "thread-42");
4182        Ok(())
4183    }
4184
4185    #[test]
4186    fn test_request_serialization_with_max_tokens_alias() {
4187        let messages = vec![ApiMessage {
4188            role: ApiRole::User,
4189            content: Some("Hello".to_string()),
4190            reasoning_content: None,
4191            tool_calls: None,
4192            tool_call_id: None,
4193            prompt_cache_breakpoint: false,
4194        }];
4195
4196        let request = ApiChatRequest {
4197            model: "glm-5",
4198            messages: &messages,
4199            max_completion_tokens: Some(1024),
4200            max_tokens: Some(1024),
4201            tools: None,
4202            tool_choice: None,
4203            reasoning: None,
4204            response_format: None,
4205            verbosity: None,
4206            prompt_cache_options: None,
4207            store: None,
4208            parallel_tool_calls: None,
4209            safety_identifier: None,
4210            prompt_cache_key: None,
4211        };
4212
4213        let json = serde_json::to_string(&request).unwrap();
4214        assert!(json.contains("\"max_completion_tokens\":1024"));
4215        assert!(json.contains("\"max_tokens\":1024"));
4216        assert!(!json.contains("\"store\""));
4217    }
4218
4219    #[test]
4220    fn test_streaming_request_serialization_openai_default() {
4221        let messages = vec![ApiMessage {
4222            role: ApiRole::User,
4223            content: Some("Hello".to_string()),
4224            reasoning_content: None,
4225            tool_calls: None,
4226            tool_call_id: None,
4227            prompt_cache_breakpoint: false,
4228        }];
4229
4230        let request = ApiChatRequestStreaming {
4231            model: "gpt-4o",
4232            messages: &messages,
4233            max_completion_tokens: Some(1024),
4234            max_tokens: None,
4235            tools: None,
4236            tool_choice: None,
4237            reasoning: None,
4238            response_format: None,
4239            verbosity: None,
4240            prompt_cache_options: None,
4241            store: Some(false),
4242            parallel_tool_calls: None,
4243            safety_identifier: None,
4244            prompt_cache_key: None,
4245            stream_options: Some(ApiStreamOptions {
4246                include_usage: true,
4247            }),
4248            usage: None,
4249            stream: true,
4250        };
4251
4252        let json = serde_json::to_string(&request).unwrap();
4253        assert!(json.contains("\"stream\":true"));
4254        assert!(json.contains("\"model\":\"gpt-4o\""));
4255        assert!(json.contains("\"max_completion_tokens\":1024"));
4256        assert!(json.contains("\"stream_options\":{\"include_usage\":true}"));
4257        assert!(!json.contains("\"max_tokens\""));
4258    }
4259
4260    #[test]
4261    fn stream_usage_is_requested_for_every_endpoint() {
4262        // issue #302: usage must be requested on ALL OpenAI-compatible
4263        // endpoints, not just api.openai.com, so OpenRouter/Baseten/local
4264        // turns report token usage to cost ledgers and budgets.
4265        assert!(use_stream_usage_options("https://api.openai.com/v1"));
4266        assert!(use_stream_usage_options("https://openrouter.ai/api/v1"));
4267        assert!(use_stream_usage_options("https://host.baseten.co/v1"));
4268        assert!(use_stream_usage_options("http://localhost:1234/v1"));
4269    }
4270
4271    #[test]
4272    fn openrouter_usage_flag_only_for_openrouter() {
4273        assert!(use_openrouter_usage_options("https://openrouter.ai/api/v1"));
4274        assert!(!use_openrouter_usage_options("https://api.openai.com/v1"));
4275    }
4276
4277    #[test]
4278    fn streaming_request_serializes_openrouter_usage_flag() -> anyhow::Result<()> {
4279        let messages = vec![ApiMessage {
4280            role: ApiRole::User,
4281            content: Some("hi".to_string()),
4282            reasoning_content: None,
4283            tool_calls: None,
4284            tool_call_id: None,
4285            prompt_cache_breakpoint: false,
4286        }];
4287        let request = ApiChatRequestStreaming {
4288            model: "anthropic/claude-3.5",
4289            messages: &messages,
4290            max_completion_tokens: Some(16),
4291            max_tokens: None,
4292            tools: None,
4293            tool_choice: None,
4294            reasoning: None,
4295            response_format: None,
4296            verbosity: None,
4297            prompt_cache_options: None,
4298            store: None,
4299            parallel_tool_calls: None,
4300            safety_identifier: None,
4301            prompt_cache_key: None,
4302            stream_options: Some(ApiStreamOptions {
4303                include_usage: true,
4304            }),
4305            usage: Some(ApiOpenRouterUsageOptions { include: true }),
4306            stream: true,
4307        };
4308        let json = serde_json::to_string(&request)?;
4309        assert!(json.contains("\"usage\":{\"include\":true}"));
4310        assert!(json.contains("\"stream_options\":{\"include_usage\":true}"));
4311        Ok(())
4312    }
4313
4314    #[test]
4315    fn usage_only_chunk_without_choices_deserializes() -> anyhow::Result<()> {
4316        // OpenAI's trailing usage frame (and some OpenRouter frames) omit
4317        // `choices` entirely; the chunk must still deserialize so the usage is
4318        // captured instead of being silently dropped (issue #302).
4319        let no_choices: SseChunk = serde_json::from_str("{}")?;
4320        assert!(no_choices.choices.is_empty());
4321
4322        let usage_only: SseChunk =
4323            serde_json::from_str(r#"{"usage":{"prompt_tokens":10,"completion_tokens":5}}"#)?;
4324        assert!(usage_only.choices.is_empty());
4325        assert!(usage_only.usage.is_some());
4326        Ok(())
4327    }
4328
4329    #[test]
4330    fn test_streaming_request_serialization_with_max_tokens_alias() {
4331        let messages = vec![ApiMessage {
4332            role: ApiRole::User,
4333            content: Some("Hello".to_string()),
4334            reasoning_content: None,
4335            tool_calls: None,
4336            tool_call_id: None,
4337            prompt_cache_breakpoint: false,
4338        }];
4339
4340        let request = ApiChatRequestStreaming {
4341            model: "kimi-k2-thinking",
4342            messages: &messages,
4343            max_completion_tokens: Some(1024),
4344            max_tokens: Some(1024),
4345            tools: None,
4346            tool_choice: None,
4347            reasoning: None,
4348            response_format: None,
4349            verbosity: None,
4350            prompt_cache_options: None,
4351            store: None,
4352            parallel_tool_calls: None,
4353            safety_identifier: None,
4354            prompt_cache_key: None,
4355            stream_options: None,
4356            usage: None,
4357            stream: true,
4358        };
4359
4360        let json = serde_json::to_string(&request).unwrap();
4361        assert!(json.contains("\"max_completion_tokens\":1024"));
4362        assert!(json.contains("\"max_tokens\":1024"));
4363        assert!(!json.contains("\"stream_options\""));
4364        assert!(!json.contains("\"store\""));
4365    }
4366
4367    #[test]
4368    fn test_request_serialization_uses_top_level_reasoning_effort() -> anyhow::Result<()> {
4369        let messages = vec![ApiMessage {
4370            role: ApiRole::User,
4371            content: Some("Hello".to_string()),
4372            reasoning_content: None,
4373            tool_calls: None,
4374            tool_call_id: None,
4375            prompt_cache_breakpoint: false,
4376        }];
4377
4378        let request = ApiChatRequest {
4379            model: MODEL_GPT54,
4380            messages: &messages,
4381            max_completion_tokens: Some(1024),
4382            max_tokens: None,
4383            tools: None,
4384            tool_choice: None,
4385            reasoning: build_chat_api_reasoning(
4386                Some(&OpenAIReasoningConfig::new().with_effort(OpenAIReasoningEffort::High)),
4387                true,
4388            ),
4389            response_format: None,
4390            verbosity: None,
4391            prompt_cache_options: None,
4392            store: Some(false),
4393            parallel_tool_calls: None,
4394            safety_identifier: None,
4395            prompt_cache_key: None,
4396        };
4397
4398        let json = serde_json::to_value(&request)?;
4399        assert_eq!(json["reasoning_effort"], "high");
4400        assert!(json.get("reasoning").is_none());
4401        Ok(())
4402    }
4403
4404    #[test]
4405    fn test_compatible_request_serialization_preserves_nested_reasoning() -> anyhow::Result<()> {
4406        let messages = vec![ApiMessage {
4407            role: ApiRole::User,
4408            content: Some("Hello".to_string()),
4409            reasoning_content: None,
4410            tool_calls: None,
4411            tool_call_id: None,
4412            prompt_cache_breakpoint: false,
4413        }];
4414        let reasoning = OpenAIReasoningConfig::new().with_effort(OpenAIReasoningEffort::High);
4415
4416        let request = ApiChatRequest {
4417            model: "compatible-model",
4418            messages: &messages,
4419            max_completion_tokens: Some(1024),
4420            max_tokens: None,
4421            tools: None,
4422            tool_choice: None,
4423            reasoning: build_chat_api_reasoning(Some(&reasoning), false),
4424            response_format: None,
4425            verbosity: None,
4426            prompt_cache_options: None,
4427            store: None,
4428            parallel_tool_calls: None,
4429            safety_identifier: None,
4430            prompt_cache_key: None,
4431        };
4432
4433        let json = serde_json::to_value(&request)?;
4434        assert_eq!(json["reasoning"]["effort"], "high");
4435        assert!(json.get("reasoning_effort").is_none());
4436        Ok(())
4437    }
4438
4439    #[test]
4440    fn test_response_format_serializes_as_json_schema() {
4441        let messages = vec![ApiMessage {
4442            role: ApiRole::User,
4443            content: Some("Hello".to_string()),
4444            reasoning_content: None,
4445            tool_calls: None,
4446            tool_call_id: None,
4447            prompt_cache_breakpoint: false,
4448        }];
4449
4450        let response_format = Some(ApiResponseFormat::from_response_format(
4451            &agent_sdk_foundation::llm::ResponseFormat::new(
4452                "person",
4453                serde_json::json!({"type": "object"}),
4454            ),
4455            true,
4456        ));
4457
4458        let request = ApiChatRequest {
4459            model: "gpt-4o",
4460            messages: &messages,
4461            max_completion_tokens: Some(1024),
4462            max_tokens: None,
4463            tools: None,
4464            tool_choice: None,
4465            reasoning: None,
4466            response_format,
4467            verbosity: None,
4468            prompt_cache_options: None,
4469            store: Some(false),
4470            parallel_tool_calls: None,
4471            safety_identifier: None,
4472            prompt_cache_key: None,
4473        };
4474
4475        let json = serde_json::to_value(&request).unwrap();
4476        assert_eq!(json["response_format"]["type"], "json_schema");
4477        assert_eq!(json["response_format"]["json_schema"]["name"], "person");
4478        assert_eq!(json["response_format"]["json_schema"]["strict"], true);
4479        assert_eq!(
4480            json["response_format"]["json_schema"]["schema"]["type"],
4481            "object"
4482        );
4483    }
4484
4485    #[test]
4486    fn test_step_completion_stream_emits_trailing_usage_after_finish_reason() {
4487        // Official OpenAI with stream_options.include_usage sends the usage in a
4488        // SEPARATE chunk (choices: []) AFTER the finish_reason chunk, then [DONE].
4489        // The streaming loop must keep consuming past finish_reason so that usage
4490        // is captured and emitted (previously it returned early on Done, dropping
4491        // the usage entirely).
4492        let mut tool_calls: HashMap<usize, ToolCallAccumulator> = HashMap::new();
4493        let mut usage: Option<Usage> = None;
4494        let mut stop_reason: Option<StopReason> = None;
4495
4496        // Chunk 1: text delta + finish_reason — must NOT finalize.
4497        let o1 = step_completion_stream(
4498            r#"{"choices":[{"delta":{"content":"hi"},"finish_reason":"stop"}]}"#,
4499            &mut tool_calls,
4500            &mut usage,
4501            &mut stop_reason,
4502        );
4503        assert!(o1.terminal.is_none());
4504        assert!(matches!(stop_reason, Some(StopReason::EndTurn)));
4505
4506        // Chunk 2: usage-only trailing chunk (choices: []).
4507        let o2 = step_completion_stream(
4508            r#"{"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":5}}"#,
4509            &mut tool_calls,
4510            &mut usage,
4511            &mut stop_reason,
4512        );
4513        assert!(o2.terminal.is_none());
4514
4515        // Chunk 3: [DONE] sentinel finalizes and must carry the trailing usage.
4516        let o3 = step_completion_stream("[DONE]", &mut tool_calls, &mut usage, &mut stop_reason);
4517        let terminal = o3.terminal.expect("[DONE] finalizes the stream");
4518        assert!(terminal.iter().any(|d| matches!(
4519            d,
4520            StreamDelta::Usage(Usage {
4521                input_tokens: 10,
4522                output_tokens: 5,
4523                ..
4524            })
4525        )));
4526        assert!(terminal.iter().any(|d| matches!(
4527            d,
4528            StreamDelta::Done {
4529                stop_reason: Some(StopReason::EndTurn)
4530            }
4531        )));
4532    }
4533
4534    #[test]
4535    fn malformed_chat_sse_is_an_immediate_server_error() -> anyhow::Result<()> {
4536        let mut tool_calls = HashMap::new();
4537        let mut usage = None;
4538        let mut stop_reason = None;
4539        let outcome = step_completion_stream(
4540            "{not valid json",
4541            &mut tool_calls,
4542            &mut usage,
4543            &mut stop_reason,
4544        );
4545        let SseLineOutcome {
4546            immediate,
4547            terminal,
4548        } = outcome;
4549        let terminal = terminal.context("malformed SSE event did not terminate the stream")?;
4550
4551        assert!(immediate.is_empty());
4552        assert!(matches!(
4553            terminal.as_slice(),
4554            [StreamDelta::Error {
4555                kind: StreamErrorKind::ServerError,
4556                ..
4557            }]
4558        ));
4559        Ok(())
4560    }
4561
4562    #[test]
4563    fn non_tool_stream_terminal_suppresses_accumulated_tool_calls() {
4564        let tool_calls = HashMap::from([(
4565            0,
4566            ToolCallAccumulator {
4567                id: "call_partial".to_owned(),
4568                name: "delete_record".to_owned(),
4569                arguments: "{\"id\":".to_owned(),
4570            },
4571        )]);
4572
4573        let truncated = build_stream_end_deltas(&tool_calls, None, StopReason::MaxTokens);
4574        assert!(!truncated.iter().any(|delta| matches!(
4575            delta,
4576            StreamDelta::ToolUseStart { .. } | StreamDelta::ToolInputDelta { .. }
4577        )));
4578
4579        let tool_terminal = build_stream_end_deltas(&tool_calls, None, StopReason::ToolUse);
4580        assert!(
4581            tool_terminal
4582                .iter()
4583                .any(|delta| matches!(delta, StreamDelta::ToolUseStart { .. }))
4584        );
4585    }
4586
4587    #[test]
4588    fn test_response_format_omitted_when_absent() {
4589        let messages = vec![ApiMessage {
4590            role: ApiRole::User,
4591            content: Some("Hello".to_string()),
4592            reasoning_content: None,
4593            tool_calls: None,
4594            tool_call_id: None,
4595            prompt_cache_breakpoint: false,
4596        }];
4597
4598        let request = ApiChatRequest {
4599            model: "gpt-4o",
4600            messages: &messages,
4601            max_completion_tokens: Some(1024),
4602            max_tokens: None,
4603            tools: None,
4604            tool_choice: None,
4605            reasoning: None,
4606            response_format: None,
4607            verbosity: None,
4608            prompt_cache_options: None,
4609            store: Some(false),
4610            parallel_tool_calls: None,
4611            safety_identifier: None,
4612            prompt_cache_key: None,
4613        };
4614
4615        let json = serde_json::to_string(&request).unwrap();
4616        assert!(!json.contains("response_format"));
4617    }
4618
4619    #[tokio::test]
4620    async fn stream_eof_before_done_is_an_error() -> anyhow::Result<()> {
4621        use wiremock::matchers::{method, path};
4622        use wiremock::{Mock, MockServer, ResponseTemplate};
4623
4624        let server = MockServer::start().await;
4625        Mock::given(method("POST"))
4626            .and(path("/chat/completions"))
4627            .respond_with(
4628                ResponseTemplate::new(200)
4629                    .insert_header("content-type", "text/event-stream")
4630                    .set_body_string(
4631                        "data: {\"choices\":[{\"delta\":{\"content\":\"partial\"},\"finish_reason\":\"stop\"}]}\n\n",
4632                    ),
4633            )
4634            .mount(&server)
4635            .await;
4636
4637        let provider = OpenAIProvider::with_base_url("test-key", MODEL_GPT4O, server.uri());
4638        let request = ChatRequest::new(
4639            String::new(),
4640            vec![agent_sdk_foundation::llm::Message::user("hello")],
4641        );
4642        let items = provider.chat_stream(request).collect::<Vec<_>>().await;
4643
4644        assert!(items.iter().any(|item| matches!(
4645            item,
4646            Ok(StreamDelta::TextDelta { delta, .. }) if delta == "partial"
4647        )));
4648        assert!(
4649            !items
4650                .iter()
4651                .any(|item| matches!(item, Ok(StreamDelta::Done { .. })))
4652        );
4653        let last = items.last().context("stream emitted no events")?;
4654        let Err(error) = last else {
4655            anyhow::bail!("truncated stream did not end with an error");
4656        };
4657        assert!(error.to_string().contains("before [DONE] sentinel"));
4658        Ok(())
4659    }
4660}