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