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