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