Skip to main content

agent_sdk_providers/impls/
openai.rs

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