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//! Legacy models that require the Responses API (like `gpt-5.2-codex`) are automatically
8//! routed to the correct endpoint.
9
10use crate::attachments::{request_has_attachments, validate_request_attachments};
11use crate::provider::LlmProvider;
12use crate::streaming::{StreamBox, StreamDelta, StreamErrorKind};
13use agent_sdk_foundation::llm::{
14    ChatOutcome, ChatRequest, ChatResponse, Content, ContentBlock, Effort, StopReason,
15    ThinkingConfig, ThinkingMode, Usage,
16};
17use anyhow::Result;
18use async_trait::async_trait;
19use futures::StreamExt;
20use reqwest::StatusCode;
21use serde::de::Error as _;
22use serde::{Deserialize, Serialize};
23
24use super::openai_responses::OpenAIResponsesProvider;
25
26const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
27
28/// Check if a model requires the Responses API instead of Chat Completions.
29fn requires_responses_api(model: &str) -> bool {
30    model == MODEL_GPT52_CODEX
31}
32
33fn is_official_openai_base_url(base_url: &str) -> bool {
34    base_url == DEFAULT_BASE_URL || base_url.contains("api.openai.com")
35}
36
37fn request_is_agentic(request: &ChatRequest) -> bool {
38    request
39        .tools
40        .as_ref()
41        .is_some_and(|tools| !tools.is_empty()) || request.messages.iter().any(|message| {
42        matches!(
43            &message.content,
44            Content::Blocks(blocks)
45                if blocks.iter().any(|block| {
46                    matches!(block, ContentBlock::ToolUse { .. } | ContentBlock::ToolResult { .. })
47                })
48        )
49    })
50}
51
52fn should_use_responses_api(base_url: &str, model: &str, request: &ChatRequest) -> bool {
53    requires_responses_api(model)
54        || request_has_attachments(request)
55        || (is_official_openai_base_url(base_url) && request_is_agentic(request))
56}
57
58// GPT-5.4 series
59pub const MODEL_GPT54: &str = "gpt-5.4";
60
61// GPT-5.3 Codex series
62pub const MODEL_GPT53_CODEX: &str = "gpt-5.3-codex";
63
64// GPT-5.2 series
65pub const MODEL_GPT52_INSTANT: &str = "gpt-5.2-instant";
66pub const MODEL_GPT52_THINKING: &str = "gpt-5.2-thinking";
67pub const MODEL_GPT52_PRO: &str = "gpt-5.2-pro";
68pub const MODEL_GPT52_CODEX: &str = "gpt-5.2-codex";
69
70// GPT-5 series (400k context)
71pub const MODEL_GPT5: &str = "gpt-5";
72pub const MODEL_GPT5_MINI: &str = "gpt-5-mini";
73pub const MODEL_GPT5_NANO: &str = "gpt-5-nano";
74
75// o-series reasoning models
76pub const MODEL_O3: &str = "o3";
77pub const MODEL_O3_MINI: &str = "o3-mini";
78pub const MODEL_O4_MINI: &str = "o4-mini";
79pub const MODEL_O1: &str = "o1";
80pub const MODEL_O1_MINI: &str = "o1-mini";
81
82// GPT-4.1 series (improved instruction following, 1M context)
83pub const MODEL_GPT41: &str = "gpt-4.1";
84pub const MODEL_GPT41_MINI: &str = "gpt-4.1-mini";
85pub const MODEL_GPT41_NANO: &str = "gpt-4.1-nano";
86
87// GPT-4o series
88pub const MODEL_GPT4O: &str = "gpt-4o";
89pub const MODEL_GPT4O_MINI: &str = "gpt-4o-mini";
90
91// OpenAI-compatible vendor defaults
92pub const BASE_URL_KIMI: &str = "https://api.moonshot.ai/v1";
93pub const BASE_URL_ZAI: &str = "https://api.z.ai/api/paas/v4";
94pub const BASE_URL_MINIMAX: &str = "https://api.minimax.io/v1";
95pub const MODEL_KIMI_K2_5: &str = "kimi-k2.5";
96pub const MODEL_KIMI_K2_THINKING: &str = "kimi-k2-thinking";
97pub const MODEL_ZAI_GLM5: &str = "glm-5";
98pub const MODEL_MINIMAX_M2_5: &str = "MiniMax-M2.5";
99
100/// `OpenAI` LLM provider using the Chat Completions API.
101///
102/// Also supports `OpenAI`-compatible APIs (Ollama, vLLM, Azure `OpenAI`, etc.)
103/// via the `with_base_url` constructor.
104#[derive(Clone)]
105pub struct OpenAIProvider {
106    client: reqwest::Client,
107    api_key: String,
108    model: String,
109    base_url: String,
110    thinking: Option<ThinkingConfig>,
111    /// Extra headers applied to every request (e.g. for gateway authentication).
112    extra_headers: Vec<(String, String)>,
113}
114
115impl OpenAIProvider {
116    /// The conventional environment variable holding the `OpenAI` API key.
117    pub const API_KEY_ENV: &'static str = "OPENAI_API_KEY";
118
119    /// Create a new `OpenAI` provider with the specified API key and model.
120    #[must_use]
121    pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
122        Self {
123            client: reqwest::Client::new(),
124            api_key: api_key.into(),
125            model: model.into(),
126            base_url: DEFAULT_BASE_URL.to_owned(),
127            thinking: None,
128            extra_headers: Vec::new(),
129        }
130    }
131
132    /// Create a provider using GPT-5, reading the API key from the
133    /// conventional [`OPENAI_API_KEY`](Self::API_KEY_ENV) environment variable.
134    ///
135    /// # Panics
136    ///
137    /// Panics if `OPENAI_API_KEY` is not set. Prefer
138    /// [`try_from_env`](Self::try_from_env) outside of examples/tests.
139    #[must_use]
140    pub fn from_env() -> Self {
141        Self::try_from_env().unwrap_or_else(|e| panic!("{e}"))
142    }
143
144    /// Create a provider using GPT-5, reading the API key from the
145    /// conventional [`OPENAI_API_KEY`](Self::API_KEY_ENV) environment variable.
146    ///
147    /// # Errors
148    ///
149    /// Returns an error if `OPENAI_API_KEY` is unset or not valid UTF-8.
150    pub fn try_from_env() -> Result<Self> {
151        let api_key = std::env::var(Self::API_KEY_ENV).map_err(|_| {
152            anyhow::anyhow!("environment variable `{}` is not set", Self::API_KEY_ENV)
153        })?;
154        Ok(Self::gpt5(api_key))
155    }
156
157    /// Create a new provider with a custom base URL for OpenAI-compatible APIs.
158    #[must_use]
159    pub fn with_base_url(
160        api_key: impl Into<String>,
161        model: impl Into<String>,
162        base_url: impl Into<String>,
163    ) -> Self {
164        Self {
165            client: reqwest::Client::new(),
166            api_key: api_key.into(),
167            model: model.into(),
168            base_url: base_url.into(),
169            thinking: None,
170            extra_headers: Vec::new(),
171        }
172    }
173
174    /// Create a provider using Moonshot KIMI via OpenAI-compatible Chat Completions.
175    #[must_use]
176    pub fn kimi(api_key: String, model: String) -> Self {
177        Self::with_base_url(api_key, model, BASE_URL_KIMI.to_owned())
178    }
179
180    /// Create a provider using KIMI K2.5 (default KIMI model).
181    #[must_use]
182    pub fn kimi_k2_5(api_key: String) -> Self {
183        Self::kimi(api_key, MODEL_KIMI_K2_5.to_owned())
184    }
185
186    /// Create a provider using KIMI K2 Thinking.
187    #[must_use]
188    pub fn kimi_k2_thinking(api_key: String) -> Self {
189        Self::kimi(api_key, MODEL_KIMI_K2_THINKING.to_owned())
190    }
191
192    /// Create a provider using z.ai via OpenAI-compatible Chat Completions.
193    #[must_use]
194    pub fn zai(api_key: String, model: String) -> Self {
195        Self::with_base_url(api_key, model, BASE_URL_ZAI.to_owned())
196    }
197
198    /// Create a provider using z.ai GLM-5 (default z.ai agentic reasoning model).
199    #[must_use]
200    pub fn zai_glm5(api_key: String) -> Self {
201        Self::zai(api_key, MODEL_ZAI_GLM5.to_owned())
202    }
203
204    /// Create a provider using `MiniMax` via OpenAI-compatible Chat Completions.
205    #[must_use]
206    pub fn minimax(api_key: String, model: String) -> Self {
207        Self::with_base_url(api_key, model, BASE_URL_MINIMAX.to_owned())
208    }
209
210    /// Create a provider using `MiniMax` M2.5 (default `MiniMax` model).
211    #[must_use]
212    pub fn minimax_m2_5(api_key: String) -> Self {
213        Self::minimax(api_key, MODEL_MINIMAX_M2_5.to_owned())
214    }
215
216    /// Create a provider using GPT-5.2 Instant (speed-optimized for routine queries).
217    #[must_use]
218    pub fn gpt52_instant(api_key: String) -> Self {
219        Self::new(api_key, MODEL_GPT52_INSTANT.to_owned())
220    }
221
222    /// Create a provider using GPT-5.4 (frontier reasoning with 1.05M context).
223    #[must_use]
224    pub fn gpt54(api_key: String) -> Self {
225        Self::new(api_key, MODEL_GPT54.to_owned())
226    }
227
228    /// Create a provider using GPT-5.3 Codex (latest codex model).
229    #[must_use]
230    pub fn gpt53_codex(api_key: String) -> Self {
231        Self::new(api_key, MODEL_GPT53_CODEX.to_owned())
232    }
233
234    /// Create a provider using GPT-5.2 Thinking (complex reasoning, coding, analysis).
235    #[must_use]
236    pub fn gpt52_thinking(api_key: String) -> Self {
237        Self::new(api_key, MODEL_GPT52_THINKING.to_owned())
238    }
239
240    /// Create a provider using GPT-5.2 Pro (maximum accuracy for difficult problems).
241    #[must_use]
242    pub fn gpt52_pro(api_key: String) -> Self {
243        Self::new(api_key, MODEL_GPT52_PRO.to_owned())
244    }
245
246    /// Create a provider using the latest Codex model.
247    #[must_use]
248    pub fn codex(api_key: String) -> Self {
249        Self::gpt53_codex(api_key)
250    }
251
252    /// Create a provider using GPT-5 (400k context, coding and reasoning).
253    #[must_use]
254    pub fn gpt5(api_key: String) -> Self {
255        Self::new(api_key, MODEL_GPT5.to_owned())
256    }
257
258    /// Create a provider using GPT-5-mini (faster, cost-efficient GPT-5).
259    #[must_use]
260    pub fn gpt5_mini(api_key: String) -> Self {
261        Self::new(api_key, MODEL_GPT5_MINI.to_owned())
262    }
263
264    /// Create a provider using GPT-5-nano (fastest, cheapest GPT-5 variant).
265    #[must_use]
266    pub fn gpt5_nano(api_key: String) -> Self {
267        Self::new(api_key, MODEL_GPT5_NANO.to_owned())
268    }
269
270    /// Create a provider using o3 (most intelligent reasoning model).
271    #[must_use]
272    pub fn o3(api_key: String) -> Self {
273        Self::new(api_key, MODEL_O3.to_owned())
274    }
275
276    /// Create a provider using o3-mini (smaller o3 variant).
277    #[must_use]
278    pub fn o3_mini(api_key: String) -> Self {
279        Self::new(api_key, MODEL_O3_MINI.to_owned())
280    }
281
282    /// Create a provider using o4-mini (fast, cost-efficient reasoning).
283    #[must_use]
284    pub fn o4_mini(api_key: String) -> Self {
285        Self::new(api_key, MODEL_O4_MINI.to_owned())
286    }
287
288    /// Create a provider using o1 (reasoning model).
289    #[must_use]
290    pub fn o1(api_key: String) -> Self {
291        Self::new(api_key, MODEL_O1.to_owned())
292    }
293
294    /// Create a provider using o1-mini (fast reasoning model).
295    #[must_use]
296    pub fn o1_mini(api_key: String) -> Self {
297        Self::new(api_key, MODEL_O1_MINI.to_owned())
298    }
299
300    /// Create a provider using GPT-4.1 (improved instruction following, 1M context).
301    #[must_use]
302    pub fn gpt41(api_key: String) -> Self {
303        Self::new(api_key, MODEL_GPT41.to_owned())
304    }
305
306    /// Create a provider using GPT-4.1-mini (smaller, faster GPT-4.1).
307    #[must_use]
308    pub fn gpt41_mini(api_key: String) -> Self {
309        Self::new(api_key, MODEL_GPT41_MINI.to_owned())
310    }
311
312    /// Create a provider using GPT-4o.
313    #[must_use]
314    pub fn gpt4o(api_key: String) -> Self {
315        Self::new(api_key, MODEL_GPT4O.to_owned())
316    }
317
318    /// Create a provider using GPT-4o-mini (fast and cost-effective).
319    #[must_use]
320    pub fn gpt4o_mini(api_key: String) -> Self {
321        Self::new(api_key, MODEL_GPT4O_MINI.to_owned())
322    }
323
324    /// Set the provider-owned thinking configuration for this model.
325    #[must_use]
326    pub const fn with_thinking(mut self, thinking: ThinkingConfig) -> Self {
327        self.thinking = Some(thinking);
328        self
329    }
330
331    /// Add extra HTTP headers applied to every request.
332    #[must_use]
333    pub fn with_extra_headers(mut self, headers: Vec<(String, String)>) -> Self {
334        self.extra_headers = headers;
335        self
336    }
337
338    /// Apply auth + extra headers. Skips `Authorization` when `api_key` is
339    /// empty (BYOK gateway mode — auth handled via `extra_headers`).
340    fn apply_headers(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
341        let builder = if self.api_key.is_empty() {
342            builder
343        } else {
344            builder.header("Authorization", format!("Bearer {}", self.api_key))
345        };
346        self.extra_headers
347            .iter()
348            .fold(builder, |b, (k, v)| b.header(k.as_str(), v.as_str()))
349    }
350}
351
352#[async_trait]
353impl LlmProvider for OpenAIProvider {
354    async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
355        // Route official OpenAI agentic flows to the Responses API.
356        if should_use_responses_api(&self.base_url, &self.model, &request) {
357            let mut responses_provider = OpenAIResponsesProvider::with_base_url(
358                self.api_key.clone(),
359                self.model.clone(),
360                self.base_url.clone(),
361            );
362            if let Some(thinking) = self.thinking.clone() {
363                responses_provider = responses_provider.with_thinking(thinking);
364            }
365            return responses_provider.chat(request).await;
366        }
367
368        let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
369            Ok(thinking) => thinking,
370            Err(error) => return Ok(ChatOutcome::InvalidRequest(error.to_string())),
371        };
372        if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
373            return Ok(ChatOutcome::InvalidRequest(error.to_string()));
374        }
375        let reasoning = build_api_reasoning(thinking_config.as_ref());
376        let messages = build_api_messages(&request);
377        let tools: Option<Vec<ApiTool>> = request
378            .tools
379            .map(|ts| ts.into_iter().map(convert_tool).collect());
380        let tool_choice = request
381            .tool_choice
382            .as_ref()
383            .map(ApiToolChoice::from_tool_choice);
384        let response_format = request
385            .response_format
386            .as_ref()
387            .map(ApiResponseFormat::from_response_format);
388
389        let include_max_tokens_alias = use_max_tokens_alias(&self.base_url);
390        let api_request = ApiChatRequest {
391            model: &self.model,
392            messages: &messages,
393            max_completion_tokens: Some(request.max_tokens),
394            max_tokens: include_max_tokens_alias.then_some(request.max_tokens),
395            tools: tools.as_deref(),
396            tool_choice,
397            reasoning,
398            response_format,
399        };
400
401        log::debug!(
402            "OpenAI LLM request model={} max_tokens={}",
403            self.model,
404            request.max_tokens
405        );
406
407        let builder = self
408            .client
409            .post(format!("{}/chat/completions", self.base_url))
410            .header("Content-Type", "application/json");
411        let response = self
412            .apply_headers(builder)
413            .json(&api_request)
414            .send()
415            .await
416            .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
417
418        let status = response.status();
419        let bytes = response
420            .bytes()
421            .await
422            .map_err(|e| anyhow::anyhow!("failed to read response body: {e}"))?;
423
424        log::debug!(
425            "OpenAI LLM response status={} body_len={}",
426            status,
427            bytes.len()
428        );
429
430        decode_chat_response(status, &bytes)
431    }
432
433    #[allow(clippy::too_many_lines)]
434    fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
435        // Route official OpenAI agentic flows to the Responses API.
436        if should_use_responses_api(&self.base_url, &self.model, &request) {
437            let api_key = self.api_key.clone();
438            let model = self.model.clone();
439            let base_url = self.base_url.clone();
440            let thinking = self.thinking.clone();
441            return Box::pin(async_stream::stream! {
442                let mut responses_provider =
443                    OpenAIResponsesProvider::with_base_url(api_key, model, base_url);
444                if let Some(thinking) = thinking {
445                    responses_provider = responses_provider.with_thinking(thinking);
446                }
447                let mut stream = std::pin::pin!(responses_provider.chat_stream(request));
448                while let Some(item) = futures::StreamExt::next(&mut stream).await {
449                    yield item;
450                }
451            });
452        }
453
454        Box::pin(async_stream::stream! {
455            let thinking_config = match self.resolve_thinking_config(request.thinking.as_ref()) {
456                Ok(thinking) => thinking,
457                Err(error) => {
458                    yield Ok(StreamDelta::Error {
459                        message: error.to_string(),
460                        kind: StreamErrorKind::InvalidRequest,
461                    });
462                    return;
463                }
464            };
465            if let Err(error) = validate_request_attachments(self.provider(), self.model(), &request) {
466                yield Ok(StreamDelta::Error {
467                    message: error.to_string(),
468                    kind: StreamErrorKind::InvalidRequest,
469                });
470                return;
471            }
472            let reasoning = build_api_reasoning(thinking_config.as_ref());
473            let messages = build_api_messages(&request);
474            let tools: Option<Vec<ApiTool>> = request
475                .tools
476                .map(|ts| ts.into_iter().map(convert_tool).collect());
477            let tool_choice = request
478                .tool_choice
479                .as_ref()
480                .map(ApiToolChoice::from_tool_choice);
481            let response_format = request
482                .response_format
483                .as_ref()
484                .map(ApiResponseFormat::from_response_format);
485
486            let include_max_tokens_alias = use_max_tokens_alias(&self.base_url);
487            let include_stream_usage = use_stream_usage_options(&self.base_url);
488            let api_request = ApiChatRequestStreaming {
489                model: &self.model,
490                messages: &messages,
491                max_completion_tokens: Some(request.max_tokens),
492                max_tokens: include_max_tokens_alias.then_some(request.max_tokens),
493                tools: tools.as_deref(),
494                tool_choice,
495                reasoning,
496                response_format,
497                stream_options: include_stream_usage.then_some(ApiStreamOptions {
498                    include_usage: true,
499                }),
500                stream: true,
501            };
502
503            log::debug!("OpenAI streaming LLM request model={} max_tokens={}", self.model, request.max_tokens);
504
505            let stream_builder = self.client
506                .post(format!("{}/chat/completions", self.base_url))
507                .header("Content-Type", "application/json");
508            let Ok(response) = self
509                .apply_headers(stream_builder)
510                .json(&api_request)
511                .send()
512                .await
513            else {
514                yield Err(anyhow::anyhow!("request failed"));
515                return;
516            };
517
518            let status = response.status();
519
520            if !status.is_success() {
521                let body = response.text().await.unwrap_or_default();
522                let (kind, level) = if status == StatusCode::TOO_MANY_REQUESTS {
523                    (StreamErrorKind::RateLimited, "rate_limit")
524                } else if status.is_server_error() {
525                    (StreamErrorKind::ServerError, "server_error")
526                } else {
527                    (StreamErrorKind::InvalidRequest, "client_error")
528                };
529                log::warn!("OpenAI error status={status} body={body} kind={level}");
530                yield Ok(StreamDelta::Error { message: body, kind });
531                return;
532            }
533
534            // Track tool call state across deltas
535            let mut tool_calls: std::collections::HashMap<usize, ToolCallAccumulator> =
536                std::collections::HashMap::new();
537            let mut usage: Option<Usage> = None;
538            let mut buffer = String::new();
539            let mut stream = response.bytes_stream();
540
541            while let Some(chunk_result) = stream.next().await {
542                let Ok(chunk) = chunk_result else {
543                    yield Err(anyhow::anyhow!("stream error: {}", chunk_result.unwrap_err()));
544                    return;
545                };
546                buffer.push_str(&String::from_utf8_lossy(&chunk));
547
548                while let Some(pos) = buffer.find('\n') {
549                    let line = buffer[..pos].trim().to_string();
550                    buffer = buffer[pos + 1..].to_string();
551                    if line.is_empty() { continue; }
552                    let Some(data) = line.strip_prefix("data: ") else { continue; };
553
554                    for result in process_sse_data(data) {
555                        match result {
556                            SseProcessResult::TextDelta(c) => yield Ok(StreamDelta::TextDelta { delta: c, block_index: 0 }),
557                            SseProcessResult::ToolCallUpdate { index, id, name, arguments } => apply_tool_call_update(&mut tool_calls, index, id, name, arguments),
558                            SseProcessResult::Usage(u) => usage = Some(u),
559                            SseProcessResult::Done(sr) => {
560                                for d in build_stream_end_deltas(&tool_calls, usage.take(), sr) { yield Ok(d); }
561                                return;
562                            }
563                            SseProcessResult::Sentinel => {
564                                let sr = if tool_calls.is_empty() { StopReason::EndTurn } else { StopReason::ToolUse };
565                                for d in build_stream_end_deltas(&tool_calls, usage.take(), sr) { yield Ok(d); }
566                                return;
567                            }
568                        }
569                    }
570                }
571            }
572
573            // Stream ended without [DONE] - emit what we have
574            for delta in build_stream_end_deltas(&tool_calls, usage, StopReason::EndTurn) {
575                yield Ok(delta);
576            }
577        })
578    }
579
580    fn model(&self) -> &str {
581        &self.model
582    }
583
584    fn provider(&self) -> &'static str {
585        "openai"
586    }
587
588    fn configured_thinking(&self) -> Option<&ThinkingConfig> {
589        self.thinking.as_ref()
590    }
591}
592
593/// Apply a tool call update to the accumulator.
594fn apply_tool_call_update(
595    tool_calls: &mut std::collections::HashMap<usize, ToolCallAccumulator>,
596    index: usize,
597    id: Option<String>,
598    name: Option<String>,
599    arguments: Option<String>,
600) {
601    let entry = tool_calls
602        .entry(index)
603        .or_insert_with(|| ToolCallAccumulator {
604            id: String::new(),
605            name: String::new(),
606            arguments: String::new(),
607        });
608    if let Some(id) = id {
609        entry.id = id;
610    }
611    if let Some(name) = name {
612        entry.name = name;
613    }
614    if let Some(args) = arguments {
615        entry.arguments.push_str(&args);
616    }
617}
618
619/// Helper to emit tool call deltas and done event.
620fn build_stream_end_deltas(
621    tool_calls: &std::collections::HashMap<usize, ToolCallAccumulator>,
622    usage: Option<Usage>,
623    stop_reason: StopReason,
624) -> Vec<StreamDelta> {
625    let mut deltas = Vec::new();
626
627    // Emit tool calls
628    for (idx, tool) in tool_calls {
629        deltas.push(StreamDelta::ToolUseStart {
630            id: tool.id.clone(),
631            name: tool.name.clone(),
632            block_index: *idx + 1,
633            thought_signature: None,
634        });
635        deltas.push(StreamDelta::ToolInputDelta {
636            id: tool.id.clone(),
637            delta: tool.arguments.clone(),
638            block_index: *idx + 1,
639        });
640    }
641
642    // Emit usage
643    if let Some(u) = usage {
644        deltas.push(StreamDelta::Usage(u));
645    }
646
647    // Emit done
648    deltas.push(StreamDelta::Done {
649        stop_reason: Some(stop_reason),
650    });
651
652    deltas
653}
654
655/// Result of processing an SSE chunk.
656enum SseProcessResult {
657    /// Emit a text delta.
658    TextDelta(String),
659    /// Update tool call accumulator (index, optional id, optional name, optional args).
660    ToolCallUpdate {
661        index: usize,
662        id: Option<String>,
663        name: Option<String>,
664        arguments: Option<String>,
665    },
666    /// Usage information.
667    Usage(Usage),
668    /// Stream is done with a stop reason.
669    Done(StopReason),
670    /// Stream sentinel [DONE] was received.
671    Sentinel,
672}
673
674/// Process an SSE data line and return results to apply.
675fn process_sse_data(data: &str) -> Vec<SseProcessResult> {
676    if data == "[DONE]" {
677        return vec![SseProcessResult::Sentinel];
678    }
679
680    let Ok(chunk) = serde_json::from_str::<SseChunk>(data) else {
681        return vec![];
682    };
683
684    let mut results = Vec::new();
685
686    // Extract usage if present
687    if let Some(u) = chunk.usage {
688        results.push(SseProcessResult::Usage(Usage {
689            input_tokens: u.prompt_tokens,
690            output_tokens: u.completion_tokens,
691            cached_input_tokens: u
692                .prompt_tokens_details
693                .as_ref()
694                .map_or(0, |details| details.cached_tokens),
695            cache_creation_input_tokens: 0,
696        }));
697    }
698
699    // Process choices
700    if let Some(choice) = chunk.choices.into_iter().next() {
701        // Handle text content delta
702        if let Some(content) = choice.delta.content
703            && !content.is_empty()
704        {
705            results.push(SseProcessResult::TextDelta(content));
706        }
707
708        // Handle tool call deltas
709        if let Some(tc_deltas) = choice.delta.tool_calls {
710            for tc in tc_deltas {
711                results.push(SseProcessResult::ToolCallUpdate {
712                    index: tc.index,
713                    id: tc.id,
714                    name: tc.function.as_ref().and_then(|f| f.name.clone()),
715                    arguments: tc.function.as_ref().and_then(|f| f.arguments.clone()),
716                });
717            }
718        }
719
720        // Check for finish reason
721        if let Some(finish_reason) = choice.finish_reason {
722            results.push(SseProcessResult::Done(map_finish_reason(&finish_reason)));
723        }
724    }
725
726    results
727}
728
729fn use_max_tokens_alias(base_url: &str) -> bool {
730    base_url.contains("moonshot.ai")
731        || base_url.contains("api.z.ai")
732        || base_url.contains("minimax.io")
733}
734
735fn use_stream_usage_options(base_url: &str) -> bool {
736    base_url == DEFAULT_BASE_URL || base_url.contains("api.openai.com")
737}
738
739/// Map an HTTP status + body into a [`ChatOutcome`], parsing the success body
740/// into a [`ChatResponse`].
741fn decode_chat_response(status: StatusCode, bytes: &[u8]) -> Result<ChatOutcome> {
742    if status == StatusCode::TOO_MANY_REQUESTS {
743        return Ok(ChatOutcome::RateLimited);
744    }
745
746    if status.is_server_error() {
747        let body = String::from_utf8_lossy(bytes);
748        log::error!("OpenAI server error status={status} body={body}");
749        return Ok(ChatOutcome::ServerError(body.into_owned()));
750    }
751
752    if status.is_client_error() {
753        let body = String::from_utf8_lossy(bytes);
754        log::warn!("OpenAI client error status={status} body={body}");
755        return Ok(ChatOutcome::InvalidRequest(body.into_owned()));
756    }
757
758    let api_response: ApiChatResponse = serde_json::from_slice(bytes)
759        .map_err(|e| anyhow::anyhow!("failed to parse response: {e}"))?;
760
761    let choice = api_response
762        .choices
763        .into_iter()
764        .next()
765        .ok_or_else(|| anyhow::anyhow!("no choices in response"))?;
766
767    let content = build_content_blocks(&choice.message);
768    let stop_reason = choice.finish_reason.as_deref().map(map_finish_reason);
769
770    Ok(ChatOutcome::Success(ChatResponse {
771        id: api_response.id,
772        content,
773        model: api_response.model,
774        stop_reason,
775        usage: Usage {
776            input_tokens: api_response.usage.prompt_tokens,
777            output_tokens: api_response.usage.completion_tokens,
778            cached_input_tokens: api_response
779                .usage
780                .prompt_tokens_details
781                .as_ref()
782                .map_or(0, |details| details.cached_tokens),
783            cache_creation_input_tokens: 0,
784        },
785    }))
786}
787
788fn map_finish_reason(finish_reason: &str) -> StopReason {
789    match finish_reason {
790        "stop" => StopReason::EndTurn,
791        "tool_calls" => StopReason::ToolUse,
792        "length" => StopReason::MaxTokens,
793        "content_filter" | "network_error" => StopReason::StopSequence,
794        "sensitive" => StopReason::Refusal,
795        unknown => {
796            log::debug!("Unknown finish_reason from OpenAI-compatible API: {unknown}");
797            StopReason::StopSequence
798        }
799    }
800}
801
802fn build_api_reasoning(thinking: Option<&ThinkingConfig>) -> Option<ApiReasoning> {
803    thinking
804        .and_then(resolve_reasoning_effort)
805        .map(|effort| ApiReasoning { effort })
806}
807
808const fn resolve_reasoning_effort(config: &ThinkingConfig) -> Option<ReasoningEffort> {
809    if let Some(effort) = config.effort {
810        return Some(map_effort(effort));
811    }
812
813    match &config.mode {
814        ThinkingMode::Adaptive => None,
815        ThinkingMode::Enabled { budget_tokens } => Some(map_budget_to_reasoning(*budget_tokens)),
816    }
817}
818
819const fn map_effort(effort: Effort) -> ReasoningEffort {
820    match effort {
821        Effort::Low => ReasoningEffort::Low,
822        Effort::Medium => ReasoningEffort::Medium,
823        Effort::High => ReasoningEffort::High,
824        Effort::Max => ReasoningEffort::XHigh,
825    }
826}
827
828const fn map_budget_to_reasoning(budget_tokens: u32) -> ReasoningEffort {
829    if budget_tokens <= 4_096 {
830        ReasoningEffort::Low
831    } else if budget_tokens <= 16_384 {
832        ReasoningEffort::Medium
833    } else if budget_tokens <= 32_768 {
834        ReasoningEffort::High
835    } else {
836        ReasoningEffort::XHigh
837    }
838}
839
840fn build_api_messages(request: &ChatRequest) -> Vec<ApiMessage> {
841    let mut messages = Vec::new();
842
843    // Add system message first (OpenAI uses a separate message for system prompt)
844    if !request.system.is_empty() {
845        messages.push(ApiMessage {
846            role: ApiRole::System,
847            content: Some(request.system.clone()),
848            tool_calls: None,
849            tool_call_id: None,
850        });
851    }
852
853    // Convert SDK messages to OpenAI format
854    for msg in &request.messages {
855        match &msg.content {
856            Content::Text(text) => {
857                messages.push(ApiMessage {
858                    role: match msg.role {
859                        agent_sdk_foundation::llm::Role::User => ApiRole::User,
860                        agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
861                    },
862                    content: Some(text.clone()),
863                    tool_calls: None,
864                    tool_call_id: None,
865                });
866            }
867            Content::Blocks(blocks) => {
868                // Handle mixed content blocks
869                let mut text_parts = Vec::new();
870                let mut tool_calls = Vec::new();
871
872                for block in blocks {
873                    match block {
874                        ContentBlock::Text { text } => {
875                            text_parts.push(text.clone());
876                        }
877                        ContentBlock::Thinking { .. }
878                        | ContentBlock::RedactedThinking { .. }
879                        | ContentBlock::Image { .. }
880                        | ContentBlock::Document { .. } => {
881                            // These blocks are not sent to the OpenAI API
882                        }
883                        ContentBlock::ToolUse {
884                            id, name, input, ..
885                        } => {
886                            tool_calls.push(ApiToolCall {
887                                id: id.clone(),
888                                r#type: "function".to_owned(),
889                                function: ApiFunctionCall {
890                                    name: name.clone(),
891                                    arguments: serde_json::to_string(input)
892                                        .unwrap_or_else(|_| "{}".to_owned()),
893                                },
894                            });
895                        }
896                        ContentBlock::ToolResult {
897                            tool_use_id,
898                            content,
899                            ..
900                        } => {
901                            // Tool results are separate messages in OpenAI
902                            messages.push(ApiMessage {
903                                role: ApiRole::Tool,
904                                content: Some(content.clone()),
905                                tool_calls: None,
906                                tool_call_id: Some(tool_use_id.clone()),
907                            });
908                        }
909                        // `ContentBlock` is `#[non_exhaustive]`; a block kind this
910                        // SDK version cannot represent is not sent to OpenAI.
911                        _ => {
912                            log::warn!("Skipping unrecognized OpenAI content block");
913                        }
914                    }
915                }
916
917                // Add assistant message with text and/or tool calls
918                if !text_parts.is_empty() || !tool_calls.is_empty() {
919                    let role = match msg.role {
920                        agent_sdk_foundation::llm::Role::User => ApiRole::User,
921                        agent_sdk_foundation::llm::Role::Assistant => ApiRole::Assistant,
922                    };
923
924                    // Only add if it's an assistant message or has text content
925                    if role == ApiRole::Assistant || !text_parts.is_empty() {
926                        messages.push(ApiMessage {
927                            role,
928                            content: if text_parts.is_empty() {
929                                None
930                            } else {
931                                Some(text_parts.join("\n"))
932                            },
933                            tool_calls: if tool_calls.is_empty() {
934                                None
935                            } else {
936                                Some(tool_calls)
937                            },
938                            tool_call_id: None,
939                        });
940                    }
941                }
942            }
943        }
944    }
945
946    messages
947}
948
949fn convert_tool(t: agent_sdk_foundation::llm::Tool) -> ApiTool {
950    ApiTool {
951        r#type: "function".to_owned(),
952        function: ApiFunction {
953            name: t.name,
954            description: t.description,
955            parameters: t.input_schema,
956        },
957    }
958}
959
960fn build_content_blocks(message: &ApiResponseMessage) -> Vec<ContentBlock> {
961    let mut blocks = Vec::new();
962
963    // Add text content if present
964    if let Some(content) = &message.content
965        && !content.is_empty()
966    {
967        blocks.push(ContentBlock::Text {
968            text: content.clone(),
969        });
970    }
971
972    // Add tool calls if present
973    if let Some(tool_calls) = &message.tool_calls {
974        for tc in tool_calls {
975            let input: serde_json::Value = serde_json::from_str(&tc.function.arguments)
976                .unwrap_or_else(|_| serde_json::json!({}));
977            blocks.push(ContentBlock::ToolUse {
978                id: tc.id.clone(),
979                name: tc.function.name.clone(),
980                input,
981                thought_signature: None,
982            });
983        }
984    }
985
986    blocks
987}
988
989// ============================================================================
990// API Request Types
991// ============================================================================
992
993#[derive(Serialize)]
994struct ApiChatRequest<'a> {
995    model: &'a str,
996    messages: &'a [ApiMessage],
997    #[serde(skip_serializing_if = "Option::is_none")]
998    max_completion_tokens: Option<u32>,
999    #[serde(skip_serializing_if = "Option::is_none")]
1000    max_tokens: Option<u32>,
1001    #[serde(skip_serializing_if = "Option::is_none")]
1002    tools: Option<&'a [ApiTool]>,
1003    #[serde(skip_serializing_if = "Option::is_none")]
1004    tool_choice: Option<ApiToolChoice>,
1005    #[serde(skip_serializing_if = "Option::is_none")]
1006    reasoning: Option<ApiReasoning>,
1007    #[serde(skip_serializing_if = "Option::is_none")]
1008    response_format: Option<ApiResponseFormat>,
1009}
1010
1011#[derive(Serialize)]
1012struct ApiChatRequestStreaming<'a> {
1013    model: &'a str,
1014    messages: &'a [ApiMessage],
1015    #[serde(skip_serializing_if = "Option::is_none")]
1016    max_completion_tokens: Option<u32>,
1017    #[serde(skip_serializing_if = "Option::is_none")]
1018    max_tokens: Option<u32>,
1019    #[serde(skip_serializing_if = "Option::is_none")]
1020    tools: Option<&'a [ApiTool]>,
1021    #[serde(skip_serializing_if = "Option::is_none")]
1022    tool_choice: Option<ApiToolChoice>,
1023    #[serde(skip_serializing_if = "Option::is_none")]
1024    reasoning: Option<ApiReasoning>,
1025    #[serde(skip_serializing_if = "Option::is_none")]
1026    response_format: Option<ApiResponseFormat>,
1027    #[serde(skip_serializing_if = "Option::is_none")]
1028    stream_options: Option<ApiStreamOptions>,
1029    stream: bool,
1030}
1031
1032/// `OpenAI` `tool_choice` wire format.
1033///
1034/// - `"auto"` — model decides.
1035/// - `{"type": "function", "function": {"name": "<name>"}}` — force a specific function.
1036#[derive(Serialize)]
1037#[serde(untagged)]
1038enum ApiToolChoice {
1039    String(String),
1040    Named {
1041        #[serde(rename = "type")]
1042        choice_type: String,
1043        function: ApiToolChoiceFunction,
1044    },
1045}
1046
1047#[derive(Serialize)]
1048struct ApiToolChoiceFunction {
1049    name: String,
1050}
1051
1052impl ApiToolChoice {
1053    fn from_tool_choice(tc: &agent_sdk_foundation::llm::ToolChoice) -> Self {
1054        match tc {
1055            agent_sdk_foundation::llm::ToolChoice::Auto => Self::String("auto".to_owned()),
1056            agent_sdk_foundation::llm::ToolChoice::Tool(name) => Self::Named {
1057                choice_type: "function".to_owned(),
1058                function: ApiToolChoiceFunction { name: name.clone() },
1059            },
1060        }
1061    }
1062}
1063
1064/// `OpenAI` `response_format` wire format for structured outputs.
1065///
1066/// Emits `{"type": "json_schema", "json_schema": {"name", "schema", "strict"}}`.
1067#[derive(Serialize)]
1068struct ApiResponseFormat {
1069    #[serde(rename = "type")]
1070    format_type: &'static str,
1071    json_schema: ApiJsonSchema,
1072}
1073
1074#[derive(Serialize)]
1075struct ApiJsonSchema {
1076    name: String,
1077    schema: serde_json::Value,
1078    strict: bool,
1079}
1080
1081impl ApiResponseFormat {
1082    fn from_response_format(rf: &agent_sdk_foundation::llm::ResponseFormat) -> Self {
1083        Self {
1084            format_type: "json_schema",
1085            json_schema: ApiJsonSchema {
1086                name: rf.name.clone(),
1087                schema: rf.schema.clone(),
1088                strict: rf.strict,
1089            },
1090        }
1091    }
1092}
1093
1094#[derive(Clone, Copy, Serialize)]
1095struct ApiStreamOptions {
1096    include_usage: bool,
1097}
1098
1099#[derive(Clone, Copy, Serialize)]
1100#[serde(rename_all = "lowercase")]
1101enum ReasoningEffort {
1102    Low,
1103    Medium,
1104    High,
1105    #[serde(rename = "xhigh")]
1106    XHigh,
1107}
1108
1109#[derive(Serialize)]
1110struct ApiReasoning {
1111    effort: ReasoningEffort,
1112}
1113
1114#[derive(Serialize)]
1115struct ApiMessage {
1116    role: ApiRole,
1117    #[serde(skip_serializing_if = "Option::is_none")]
1118    content: Option<String>,
1119    #[serde(skip_serializing_if = "Option::is_none")]
1120    tool_calls: Option<Vec<ApiToolCall>>,
1121    #[serde(skip_serializing_if = "Option::is_none")]
1122    tool_call_id: Option<String>,
1123}
1124
1125#[derive(Debug, Serialize, PartialEq, Eq)]
1126#[serde(rename_all = "lowercase")]
1127enum ApiRole {
1128    System,
1129    User,
1130    Assistant,
1131    Tool,
1132}
1133
1134#[derive(Serialize)]
1135struct ApiToolCall {
1136    id: String,
1137    r#type: String,
1138    function: ApiFunctionCall,
1139}
1140
1141#[derive(Serialize)]
1142struct ApiFunctionCall {
1143    name: String,
1144    arguments: String,
1145}
1146
1147#[derive(Serialize)]
1148struct ApiTool {
1149    r#type: String,
1150    function: ApiFunction,
1151}
1152
1153#[derive(Serialize)]
1154struct ApiFunction {
1155    name: String,
1156    description: String,
1157    parameters: serde_json::Value,
1158}
1159
1160// ============================================================================
1161// API Response Types
1162// ============================================================================
1163
1164#[derive(Deserialize)]
1165struct ApiChatResponse {
1166    id: String,
1167    choices: Vec<ApiChoice>,
1168    model: String,
1169    usage: ApiUsage,
1170}
1171
1172#[derive(Deserialize)]
1173struct ApiChoice {
1174    message: ApiResponseMessage,
1175    finish_reason: Option<String>,
1176}
1177
1178#[derive(Deserialize)]
1179struct ApiResponseMessage {
1180    content: Option<String>,
1181    tool_calls: Option<Vec<ApiResponseToolCall>>,
1182}
1183
1184#[derive(Deserialize)]
1185struct ApiResponseToolCall {
1186    id: String,
1187    function: ApiResponseFunctionCall,
1188}
1189
1190#[derive(Deserialize)]
1191struct ApiResponseFunctionCall {
1192    name: String,
1193    arguments: String,
1194}
1195
1196#[derive(Deserialize)]
1197struct ApiUsage {
1198    #[serde(deserialize_with = "deserialize_u32_from_number")]
1199    prompt_tokens: u32,
1200    #[serde(deserialize_with = "deserialize_u32_from_number")]
1201    completion_tokens: u32,
1202    #[serde(default)]
1203    prompt_tokens_details: Option<ApiPromptTokensDetails>,
1204}
1205
1206#[derive(Deserialize)]
1207struct ApiPromptTokensDetails {
1208    #[serde(default, deserialize_with = "deserialize_u32_from_number")]
1209    cached_tokens: u32,
1210}
1211
1212// ============================================================================
1213// SSE Streaming Types
1214// ============================================================================
1215
1216/// Accumulator for tool call state across stream deltas.
1217struct ToolCallAccumulator {
1218    id: String,
1219    name: String,
1220    arguments: String,
1221}
1222
1223/// A single chunk in `OpenAI`'s SSE stream.
1224#[derive(Deserialize)]
1225struct SseChunk {
1226    choices: Vec<SseChoice>,
1227    #[serde(default)]
1228    usage: Option<SseUsage>,
1229}
1230
1231#[derive(Deserialize)]
1232struct SseChoice {
1233    delta: SseDelta,
1234    finish_reason: Option<String>,
1235}
1236
1237#[derive(Deserialize)]
1238struct SseDelta {
1239    content: Option<String>,
1240    tool_calls: Option<Vec<SseToolCallDelta>>,
1241}
1242
1243#[derive(Deserialize)]
1244struct SseToolCallDelta {
1245    index: usize,
1246    id: Option<String>,
1247    function: Option<SseFunctionDelta>,
1248}
1249
1250#[derive(Deserialize)]
1251struct SseFunctionDelta {
1252    name: Option<String>,
1253    arguments: Option<String>,
1254}
1255
1256#[derive(Deserialize)]
1257struct SseUsage {
1258    #[serde(deserialize_with = "deserialize_u32_from_number")]
1259    prompt_tokens: u32,
1260    #[serde(deserialize_with = "deserialize_u32_from_number")]
1261    completion_tokens: u32,
1262    #[serde(default)]
1263    prompt_tokens_details: Option<ApiPromptTokensDetails>,
1264}
1265
1266fn deserialize_u32_from_number<'de, D>(deserializer: D) -> std::result::Result<u32, D::Error>
1267where
1268    D: serde::Deserializer<'de>,
1269{
1270    #[derive(Deserialize)]
1271    #[serde(untagged)]
1272    enum NumberLike {
1273        U64(u64),
1274        F64(f64),
1275    }
1276
1277    match NumberLike::deserialize(deserializer)? {
1278        NumberLike::U64(v) => u32::try_from(v)
1279            .map_err(|_| D::Error::custom(format!("token count out of range for u32: {v}"))),
1280        NumberLike::F64(v) => {
1281            if v.is_finite() && v >= 0.0 && v.fract() == 0.0 && v <= f64::from(u32::MAX) {
1282                v.to_string().parse::<u32>().map_err(|e| {
1283                    D::Error::custom(format!(
1284                        "failed to convert integer-compatible token count {v} to u32: {e}"
1285                    ))
1286                })
1287            } else {
1288                Err(D::Error::custom(format!(
1289                    "token count must be a non-negative integer-compatible number, got {v}"
1290                )))
1291            }
1292        }
1293    }
1294}
1295
1296#[cfg(test)]
1297mod tests {
1298    use super::*;
1299
1300    // ===================
1301    // Constructor Tests
1302    // ===================
1303
1304    #[test]
1305    fn test_new_creates_provider_with_custom_model() {
1306        let provider = OpenAIProvider::new("test-api-key".to_string(), "custom-model".to_string());
1307
1308        assert_eq!(provider.model(), "custom-model");
1309        assert_eq!(provider.provider(), "openai");
1310        assert_eq!(provider.base_url, DEFAULT_BASE_URL);
1311    }
1312
1313    #[test]
1314    fn test_with_base_url_creates_provider_with_custom_url() {
1315        let provider = OpenAIProvider::with_base_url(
1316            "test-api-key".to_string(),
1317            "llama3".to_string(),
1318            "http://localhost:11434/v1".to_string(),
1319        );
1320
1321        assert_eq!(provider.model(), "llama3");
1322        assert_eq!(provider.base_url, "http://localhost:11434/v1");
1323    }
1324
1325    #[test]
1326    fn test_gpt4o_factory_creates_gpt4o_provider() {
1327        let provider = OpenAIProvider::gpt4o("test-api-key".to_string());
1328
1329        assert_eq!(provider.model(), MODEL_GPT4O);
1330        assert_eq!(provider.provider(), "openai");
1331    }
1332
1333    #[test]
1334    fn test_gpt4o_mini_factory_creates_gpt4o_mini_provider() {
1335        let provider = OpenAIProvider::gpt4o_mini("test-api-key".to_string());
1336
1337        assert_eq!(provider.model(), MODEL_GPT4O_MINI);
1338        assert_eq!(provider.provider(), "openai");
1339    }
1340
1341    #[test]
1342    fn test_gpt52_thinking_factory_creates_provider() {
1343        let provider = OpenAIProvider::gpt52_thinking("test-api-key".to_string());
1344
1345        assert_eq!(provider.model(), MODEL_GPT52_THINKING);
1346        assert_eq!(provider.provider(), "openai");
1347    }
1348
1349    #[test]
1350    fn test_gpt54_factory_creates_provider() {
1351        let provider = OpenAIProvider::gpt54("test-api-key".to_string());
1352
1353        assert_eq!(provider.model(), MODEL_GPT54);
1354        assert_eq!(provider.provider(), "openai");
1355    }
1356
1357    #[test]
1358    fn test_gpt53_codex_factory_creates_provider() {
1359        let provider = OpenAIProvider::gpt53_codex("test-api-key".to_string());
1360
1361        assert_eq!(provider.model(), MODEL_GPT53_CODEX);
1362        assert_eq!(provider.provider(), "openai");
1363    }
1364
1365    #[test]
1366    fn test_codex_factory_points_to_latest_codex_model() {
1367        let provider = OpenAIProvider::codex("test-api-key".to_string());
1368
1369        assert_eq!(provider.model(), MODEL_GPT53_CODEX);
1370        assert_eq!(provider.provider(), "openai");
1371    }
1372
1373    #[test]
1374    fn test_gpt5_factory_creates_gpt5_provider() {
1375        let provider = OpenAIProvider::gpt5("test-api-key".to_string());
1376
1377        assert_eq!(provider.model(), MODEL_GPT5);
1378        assert_eq!(provider.provider(), "openai");
1379    }
1380
1381    #[test]
1382    fn test_gpt5_mini_factory_creates_provider() {
1383        let provider = OpenAIProvider::gpt5_mini("test-api-key".to_string());
1384
1385        assert_eq!(provider.model(), MODEL_GPT5_MINI);
1386        assert_eq!(provider.provider(), "openai");
1387    }
1388
1389    #[test]
1390    fn test_o3_factory_creates_o3_provider() {
1391        let provider = OpenAIProvider::o3("test-api-key".to_string());
1392
1393        assert_eq!(provider.model(), MODEL_O3);
1394        assert_eq!(provider.provider(), "openai");
1395    }
1396
1397    #[test]
1398    fn test_o4_mini_factory_creates_o4_mini_provider() {
1399        let provider = OpenAIProvider::o4_mini("test-api-key".to_string());
1400
1401        assert_eq!(provider.model(), MODEL_O4_MINI);
1402        assert_eq!(provider.provider(), "openai");
1403    }
1404
1405    #[test]
1406    fn test_o1_factory_creates_o1_provider() {
1407        let provider = OpenAIProvider::o1("test-api-key".to_string());
1408
1409        assert_eq!(provider.model(), MODEL_O1);
1410        assert_eq!(provider.provider(), "openai");
1411    }
1412
1413    #[test]
1414    fn test_gpt41_factory_creates_gpt41_provider() {
1415        let provider = OpenAIProvider::gpt41("test-api-key".to_string());
1416
1417        assert_eq!(provider.model(), MODEL_GPT41);
1418        assert_eq!(provider.provider(), "openai");
1419    }
1420
1421    #[test]
1422    fn test_kimi_factory_creates_provider_with_kimi_base_url() {
1423        let provider = OpenAIProvider::kimi("test-api-key".to_string(), "kimi-custom".to_string());
1424
1425        assert_eq!(provider.model(), "kimi-custom");
1426        assert_eq!(provider.base_url, BASE_URL_KIMI);
1427        assert_eq!(provider.provider(), "openai");
1428    }
1429
1430    #[test]
1431    fn test_kimi_k2_5_factory_creates_provider() {
1432        let provider = OpenAIProvider::kimi_k2_5("test-api-key".to_string());
1433
1434        assert_eq!(provider.model(), MODEL_KIMI_K2_5);
1435        assert_eq!(provider.base_url, BASE_URL_KIMI);
1436        assert_eq!(provider.provider(), "openai");
1437    }
1438
1439    #[test]
1440    fn test_kimi_k2_thinking_factory_creates_provider() {
1441        let provider = OpenAIProvider::kimi_k2_thinking("test-api-key".to_string());
1442
1443        assert_eq!(provider.model(), MODEL_KIMI_K2_THINKING);
1444        assert_eq!(provider.base_url, BASE_URL_KIMI);
1445        assert_eq!(provider.provider(), "openai");
1446    }
1447
1448    #[test]
1449    fn test_zai_factory_creates_provider_with_zai_base_url() {
1450        let provider = OpenAIProvider::zai("test-api-key".to_string(), "glm-custom".to_string());
1451
1452        assert_eq!(provider.model(), "glm-custom");
1453        assert_eq!(provider.base_url, BASE_URL_ZAI);
1454        assert_eq!(provider.provider(), "openai");
1455    }
1456
1457    #[test]
1458    fn test_zai_glm5_factory_creates_provider() {
1459        let provider = OpenAIProvider::zai_glm5("test-api-key".to_string());
1460
1461        assert_eq!(provider.model(), MODEL_ZAI_GLM5);
1462        assert_eq!(provider.base_url, BASE_URL_ZAI);
1463        assert_eq!(provider.provider(), "openai");
1464    }
1465
1466    #[test]
1467    fn test_minimax_factory_creates_provider_with_minimax_base_url() {
1468        let provider =
1469            OpenAIProvider::minimax("test-api-key".to_string(), "minimax-custom".to_string());
1470
1471        assert_eq!(provider.model(), "minimax-custom");
1472        assert_eq!(provider.base_url, BASE_URL_MINIMAX);
1473        assert_eq!(provider.provider(), "openai");
1474    }
1475
1476    #[test]
1477    fn test_minimax_m2_5_factory_creates_provider() {
1478        let provider = OpenAIProvider::minimax_m2_5("test-api-key".to_string());
1479
1480        assert_eq!(provider.model(), MODEL_MINIMAX_M2_5);
1481        assert_eq!(provider.base_url, BASE_URL_MINIMAX);
1482        assert_eq!(provider.provider(), "openai");
1483    }
1484
1485    // ===================
1486    // Model Constants Tests
1487    // ===================
1488
1489    #[test]
1490    fn test_model_constants_have_expected_values() {
1491        // GPT-5.4 / GPT-5.3 Codex
1492        assert_eq!(MODEL_GPT54, "gpt-5.4");
1493        assert_eq!(MODEL_GPT53_CODEX, "gpt-5.3-codex");
1494        // GPT-5.2 series
1495        assert_eq!(MODEL_GPT52_INSTANT, "gpt-5.2-instant");
1496        assert_eq!(MODEL_GPT52_THINKING, "gpt-5.2-thinking");
1497        assert_eq!(MODEL_GPT52_PRO, "gpt-5.2-pro");
1498        assert_eq!(MODEL_GPT52_CODEX, "gpt-5.2-codex");
1499        // GPT-5 series
1500        assert_eq!(MODEL_GPT5, "gpt-5");
1501        assert_eq!(MODEL_GPT5_MINI, "gpt-5-mini");
1502        assert_eq!(MODEL_GPT5_NANO, "gpt-5-nano");
1503        // o-series
1504        assert_eq!(MODEL_O3, "o3");
1505        assert_eq!(MODEL_O3_MINI, "o3-mini");
1506        assert_eq!(MODEL_O4_MINI, "o4-mini");
1507        assert_eq!(MODEL_O1, "o1");
1508        assert_eq!(MODEL_O1_MINI, "o1-mini");
1509        // GPT-4.1 series
1510        assert_eq!(MODEL_GPT41, "gpt-4.1");
1511        assert_eq!(MODEL_GPT41_MINI, "gpt-4.1-mini");
1512        assert_eq!(MODEL_GPT41_NANO, "gpt-4.1-nano");
1513        // GPT-4o series
1514        assert_eq!(MODEL_GPT4O, "gpt-4o");
1515        assert_eq!(MODEL_GPT4O_MINI, "gpt-4o-mini");
1516        // OpenAI-compatible vendor defaults
1517        assert_eq!(MODEL_KIMI_K2_5, "kimi-k2.5");
1518        assert_eq!(MODEL_KIMI_K2_THINKING, "kimi-k2-thinking");
1519        assert_eq!(MODEL_ZAI_GLM5, "glm-5");
1520        assert_eq!(MODEL_MINIMAX_M2_5, "MiniMax-M2.5");
1521        assert_eq!(BASE_URL_KIMI, "https://api.moonshot.ai/v1");
1522        assert_eq!(BASE_URL_ZAI, "https://api.z.ai/api/paas/v4");
1523        assert_eq!(BASE_URL_MINIMAX, "https://api.minimax.io/v1");
1524    }
1525
1526    // ===================
1527    // Clone Tests
1528    // ===================
1529
1530    #[test]
1531    fn test_provider_is_cloneable() {
1532        let provider = OpenAIProvider::new("test-api-key".to_string(), "test-model".to_string());
1533        let cloned = provider.clone();
1534
1535        assert_eq!(provider.model(), cloned.model());
1536        assert_eq!(provider.provider(), cloned.provider());
1537        assert_eq!(provider.base_url, cloned.base_url);
1538    }
1539
1540    // ===================
1541    // API Type Serialization Tests
1542    // ===================
1543
1544    #[test]
1545    fn test_api_role_serialization() {
1546        let system_role = ApiRole::System;
1547        let user_role = ApiRole::User;
1548        let assistant_role = ApiRole::Assistant;
1549        let tool_role = ApiRole::Tool;
1550
1551        assert_eq!(serde_json::to_string(&system_role).unwrap(), "\"system\"");
1552        assert_eq!(serde_json::to_string(&user_role).unwrap(), "\"user\"");
1553        assert_eq!(
1554            serde_json::to_string(&assistant_role).unwrap(),
1555            "\"assistant\""
1556        );
1557        assert_eq!(serde_json::to_string(&tool_role).unwrap(), "\"tool\"");
1558    }
1559
1560    #[test]
1561    fn test_api_message_serialization_simple() {
1562        let message = ApiMessage {
1563            role: ApiRole::User,
1564            content: Some("Hello, world!".to_string()),
1565            tool_calls: None,
1566            tool_call_id: None,
1567        };
1568
1569        let json = serde_json::to_string(&message).unwrap();
1570        assert!(json.contains("\"role\":\"user\""));
1571        assert!(json.contains("\"content\":\"Hello, world!\""));
1572        // Optional fields should be omitted
1573        assert!(!json.contains("tool_calls"));
1574        assert!(!json.contains("tool_call_id"));
1575    }
1576
1577    #[test]
1578    fn test_api_message_serialization_with_tool_calls() {
1579        let message = ApiMessage {
1580            role: ApiRole::Assistant,
1581            content: Some("Let me help.".to_string()),
1582            tool_calls: Some(vec![ApiToolCall {
1583                id: "call_123".to_string(),
1584                r#type: "function".to_string(),
1585                function: ApiFunctionCall {
1586                    name: "read_file".to_string(),
1587                    arguments: "{\"path\": \"/test.txt\"}".to_string(),
1588                },
1589            }]),
1590            tool_call_id: None,
1591        };
1592
1593        let json = serde_json::to_string(&message).unwrap();
1594        assert!(json.contains("\"role\":\"assistant\""));
1595        assert!(json.contains("\"tool_calls\""));
1596        assert!(json.contains("\"id\":\"call_123\""));
1597        assert!(json.contains("\"type\":\"function\""));
1598        assert!(json.contains("\"name\":\"read_file\""));
1599    }
1600
1601    #[test]
1602    fn test_api_tool_message_serialization() {
1603        let message = ApiMessage {
1604            role: ApiRole::Tool,
1605            content: Some("File contents here".to_string()),
1606            tool_calls: None,
1607            tool_call_id: Some("call_123".to_string()),
1608        };
1609
1610        let json = serde_json::to_string(&message).unwrap();
1611        assert!(json.contains("\"role\":\"tool\""));
1612        assert!(json.contains("\"tool_call_id\":\"call_123\""));
1613        assert!(json.contains("\"content\":\"File contents here\""));
1614    }
1615
1616    #[test]
1617    fn test_api_tool_serialization() {
1618        let tool = ApiTool {
1619            r#type: "function".to_string(),
1620            function: ApiFunction {
1621                name: "test_tool".to_string(),
1622                description: "A test tool".to_string(),
1623                parameters: serde_json::json!({
1624                    "type": "object",
1625                    "properties": {
1626                        "arg": {"type": "string"}
1627                    }
1628                }),
1629            },
1630        };
1631
1632        let json = serde_json::to_string(&tool).unwrap();
1633        assert!(json.contains("\"type\":\"function\""));
1634        assert!(json.contains("\"name\":\"test_tool\""));
1635        assert!(json.contains("\"description\":\"A test tool\""));
1636        assert!(json.contains("\"parameters\""));
1637    }
1638
1639    // ===================
1640    // API Type Deserialization Tests
1641    // ===================
1642
1643    #[test]
1644    fn test_api_response_deserialization() {
1645        let json = r#"{
1646            "id": "chatcmpl-123",
1647            "choices": [
1648                {
1649                    "message": {
1650                        "content": "Hello!"
1651                    },
1652                    "finish_reason": "stop"
1653                }
1654            ],
1655            "model": "gpt-4o",
1656            "usage": {
1657                "prompt_tokens": 100,
1658                "completion_tokens": 50
1659            }
1660        }"#;
1661
1662        let response: ApiChatResponse = serde_json::from_str(json).unwrap();
1663        assert_eq!(response.id, "chatcmpl-123");
1664        assert_eq!(response.model, "gpt-4o");
1665        assert_eq!(response.usage.prompt_tokens, 100);
1666        assert_eq!(response.usage.completion_tokens, 50);
1667        assert_eq!(response.choices.len(), 1);
1668        assert_eq!(
1669            response.choices[0].message.content,
1670            Some("Hello!".to_string())
1671        );
1672    }
1673
1674    #[test]
1675    fn test_api_response_with_tool_calls_deserialization() {
1676        let json = r#"{
1677            "id": "chatcmpl-456",
1678            "choices": [
1679                {
1680                    "message": {
1681                        "content": null,
1682                        "tool_calls": [
1683                            {
1684                                "id": "call_abc",
1685                                "type": "function",
1686                                "function": {
1687                                    "name": "read_file",
1688                                    "arguments": "{\"path\": \"test.txt\"}"
1689                                }
1690                            }
1691                        ]
1692                    },
1693                    "finish_reason": "tool_calls"
1694                }
1695            ],
1696            "model": "gpt-4o",
1697            "usage": {
1698                "prompt_tokens": 150,
1699                "completion_tokens": 30
1700            }
1701        }"#;
1702
1703        let response: ApiChatResponse = serde_json::from_str(json).unwrap();
1704        let tool_calls = response.choices[0].message.tool_calls.as_ref().unwrap();
1705        assert_eq!(tool_calls.len(), 1);
1706        assert_eq!(tool_calls[0].id, "call_abc");
1707        assert_eq!(tool_calls[0].function.name, "read_file");
1708    }
1709
1710    #[test]
1711    fn test_api_response_with_unknown_finish_reason_deserialization() {
1712        let json = r#"{
1713            "id": "chatcmpl-789",
1714            "choices": [
1715                {
1716                    "message": {
1717                        "content": "ok"
1718                    },
1719                    "finish_reason": "vendor_custom_reason"
1720                }
1721            ],
1722            "model": "glm-5",
1723            "usage": {
1724                "prompt_tokens": 10,
1725                "completion_tokens": 5
1726            }
1727        }"#;
1728
1729        let response: ApiChatResponse = serde_json::from_str(json).unwrap();
1730        assert_eq!(
1731            response.choices[0].finish_reason.as_deref(),
1732            Some("vendor_custom_reason")
1733        );
1734        assert_eq!(
1735            map_finish_reason(response.choices[0].finish_reason.as_deref().unwrap()),
1736            StopReason::StopSequence
1737        );
1738    }
1739
1740    #[test]
1741    fn test_map_finish_reason_covers_vendor_specific_values() {
1742        assert_eq!(map_finish_reason("stop"), StopReason::EndTurn);
1743        assert_eq!(map_finish_reason("tool_calls"), StopReason::ToolUse);
1744        assert_eq!(map_finish_reason("length"), StopReason::MaxTokens);
1745        assert_eq!(
1746            map_finish_reason("content_filter"),
1747            StopReason::StopSequence
1748        );
1749        assert_eq!(map_finish_reason("sensitive"), StopReason::Refusal);
1750        assert_eq!(map_finish_reason("network_error"), StopReason::StopSequence);
1751        assert_eq!(
1752            map_finish_reason("some_new_reason"),
1753            StopReason::StopSequence
1754        );
1755    }
1756
1757    // ===================
1758    // Message Conversion Tests
1759    // ===================
1760
1761    #[test]
1762    fn test_build_api_messages_with_system() {
1763        let request = ChatRequest {
1764            system: "You are helpful.".to_string(),
1765            messages: vec![agent_sdk_foundation::llm::Message::user("Hello")],
1766            tools: None,
1767            max_tokens: 1024,
1768            max_tokens_explicit: true,
1769            session_id: None,
1770            cached_content: None,
1771            thinking: None,
1772            tool_choice: None,
1773            response_format: None,
1774        };
1775
1776        let api_messages = build_api_messages(&request);
1777        assert_eq!(api_messages.len(), 2);
1778        assert_eq!(api_messages[0].role, ApiRole::System);
1779        assert_eq!(
1780            api_messages[0].content,
1781            Some("You are helpful.".to_string())
1782        );
1783        assert_eq!(api_messages[1].role, ApiRole::User);
1784        assert_eq!(api_messages[1].content, Some("Hello".to_string()));
1785    }
1786
1787    #[test]
1788    fn test_build_api_messages_empty_system() {
1789        let request = ChatRequest {
1790            system: String::new(),
1791            messages: vec![agent_sdk_foundation::llm::Message::user("Hello")],
1792            tools: None,
1793            max_tokens: 1024,
1794            max_tokens_explicit: true,
1795            session_id: None,
1796            cached_content: None,
1797            thinking: None,
1798            tool_choice: None,
1799            response_format: None,
1800        };
1801
1802        let api_messages = build_api_messages(&request);
1803        assert_eq!(api_messages.len(), 1);
1804        assert_eq!(api_messages[0].role, ApiRole::User);
1805    }
1806
1807    #[test]
1808    fn test_convert_tool() {
1809        let tool = agent_sdk_foundation::llm::Tool {
1810            name: "test_tool".to_string(),
1811            description: "A test tool".to_string(),
1812            input_schema: serde_json::json!({"type": "object"}),
1813            display_name: "Test Tool".to_string(),
1814            tier: agent_sdk_foundation::ToolTier::Observe,
1815        };
1816
1817        let api_tool = convert_tool(tool);
1818        assert_eq!(api_tool.r#type, "function");
1819        assert_eq!(api_tool.function.name, "test_tool");
1820        assert_eq!(api_tool.function.description, "A test tool");
1821    }
1822
1823    #[test]
1824    fn test_build_content_blocks_text_only() {
1825        let message = ApiResponseMessage {
1826            content: Some("Hello!".to_string()),
1827            tool_calls: None,
1828        };
1829
1830        let blocks = build_content_blocks(&message);
1831        assert_eq!(blocks.len(), 1);
1832        assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Hello!"));
1833    }
1834
1835    #[test]
1836    fn test_build_content_blocks_with_tool_calls() {
1837        let message = ApiResponseMessage {
1838            content: Some("Let me help.".to_string()),
1839            tool_calls: Some(vec![ApiResponseToolCall {
1840                id: "call_123".to_string(),
1841                function: ApiResponseFunctionCall {
1842                    name: "read_file".to_string(),
1843                    arguments: "{\"path\": \"test.txt\"}".to_string(),
1844                },
1845            }]),
1846        };
1847
1848        let blocks = build_content_blocks(&message);
1849        assert_eq!(blocks.len(), 2);
1850        assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Let me help."));
1851        assert!(
1852            matches!(&blocks[1], ContentBlock::ToolUse { id, name, .. } if id == "call_123" && name == "read_file")
1853        );
1854    }
1855
1856    // ===================
1857    // SSE Streaming Type Tests
1858    // ===================
1859
1860    #[test]
1861    fn test_sse_chunk_text_delta_deserialization() {
1862        let json = r#"{
1863            "choices": [{
1864                "delta": {
1865                    "content": "Hello"
1866                },
1867                "finish_reason": null
1868            }]
1869        }"#;
1870
1871        let chunk: SseChunk = serde_json::from_str(json).unwrap();
1872        assert_eq!(chunk.choices.len(), 1);
1873        assert_eq!(chunk.choices[0].delta.content, Some("Hello".to_string()));
1874        assert!(chunk.choices[0].finish_reason.is_none());
1875    }
1876
1877    #[test]
1878    fn test_sse_chunk_tool_call_delta_deserialization() {
1879        let json = r#"{
1880            "choices": [{
1881                "delta": {
1882                    "tool_calls": [{
1883                        "index": 0,
1884                        "id": "call_abc",
1885                        "function": {
1886                            "name": "read_file",
1887                            "arguments": ""
1888                        }
1889                    }]
1890                },
1891                "finish_reason": null
1892            }]
1893        }"#;
1894
1895        let chunk: SseChunk = serde_json::from_str(json).unwrap();
1896        let tool_calls = chunk.choices[0].delta.tool_calls.as_ref().unwrap();
1897        assert_eq!(tool_calls.len(), 1);
1898        assert_eq!(tool_calls[0].index, 0);
1899        assert_eq!(tool_calls[0].id, Some("call_abc".to_string()));
1900        assert_eq!(
1901            tool_calls[0].function.as_ref().unwrap().name,
1902            Some("read_file".to_string())
1903        );
1904    }
1905
1906    #[test]
1907    fn test_sse_chunk_tool_call_arguments_delta_deserialization() {
1908        let json = r#"{
1909            "choices": [{
1910                "delta": {
1911                    "tool_calls": [{
1912                        "index": 0,
1913                        "function": {
1914                            "arguments": "{\"path\":"
1915                        }
1916                    }]
1917                },
1918                "finish_reason": null
1919            }]
1920        }"#;
1921
1922        let chunk: SseChunk = serde_json::from_str(json).unwrap();
1923        let tool_calls = chunk.choices[0].delta.tool_calls.as_ref().unwrap();
1924        assert_eq!(tool_calls[0].id, None);
1925        assert_eq!(
1926            tool_calls[0].function.as_ref().unwrap().arguments,
1927            Some("{\"path\":".to_string())
1928        );
1929    }
1930
1931    #[test]
1932    fn test_sse_chunk_with_finish_reason_deserialization() {
1933        let json = r#"{
1934            "choices": [{
1935                "delta": {},
1936                "finish_reason": "stop"
1937            }]
1938        }"#;
1939
1940        let chunk: SseChunk = serde_json::from_str(json).unwrap();
1941        assert_eq!(chunk.choices[0].finish_reason.as_deref(), Some("stop"));
1942    }
1943
1944    #[test]
1945    fn test_sse_chunk_with_usage_deserialization() {
1946        let json = r#"{
1947            "choices": [{
1948                "delta": {},
1949                "finish_reason": "stop"
1950            }],
1951            "usage": {
1952                "prompt_tokens": 100,
1953                "completion_tokens": 50
1954            }
1955        }"#;
1956
1957        let chunk: SseChunk = serde_json::from_str(json).unwrap();
1958        let usage = chunk.usage.unwrap();
1959        assert_eq!(usage.prompt_tokens, 100);
1960        assert_eq!(usage.completion_tokens, 50);
1961    }
1962
1963    #[test]
1964    fn test_sse_chunk_with_float_usage_deserialization() {
1965        let json = r#"{
1966            "choices": [{
1967                "delta": {},
1968                "finish_reason": "stop"
1969            }],
1970            "usage": {
1971                "prompt_tokens": 100.0,
1972                "completion_tokens": 50.0
1973            }
1974        }"#;
1975
1976        let chunk: SseChunk = serde_json::from_str(json).unwrap();
1977        let usage = chunk.usage.unwrap();
1978        assert_eq!(usage.prompt_tokens, 100);
1979        assert_eq!(usage.completion_tokens, 50);
1980    }
1981
1982    #[test]
1983    fn test_api_usage_deserializes_integer_compatible_numbers() {
1984        let json = r#"{
1985            "prompt_tokens": 42.0,
1986            "completion_tokens": 7
1987        }"#;
1988
1989        let usage: ApiUsage = serde_json::from_str(json).unwrap();
1990        assert_eq!(usage.prompt_tokens, 42);
1991        assert_eq!(usage.completion_tokens, 7);
1992    }
1993
1994    #[test]
1995    fn test_api_usage_deserializes_cached_tokens() {
1996        let json = r#"{
1997            "prompt_tokens": 42,
1998            "completion_tokens": 7,
1999            "prompt_tokens_details": {
2000                "cached_tokens": 10
2001            }
2002        }"#;
2003
2004        let usage: ApiUsage = serde_json::from_str(json).unwrap();
2005        assert_eq!(usage.prompt_tokens, 42);
2006        assert_eq!(usage.completion_tokens, 7);
2007        assert_eq!(usage.prompt_tokens_details.unwrap().cached_tokens, 10);
2008    }
2009
2010    #[test]
2011    fn test_process_sse_data_maps_cached_tokens_to_cache_read_usage() {
2012        let results = process_sse_data(
2013            r#"{
2014                "choices": [],
2015                "usage": {
2016                    "prompt_tokens": 42,
2017                    "completion_tokens": 7,
2018                    "prompt_tokens_details": {
2019                        "cached_tokens": 10
2020                    }
2021                }
2022            }"#,
2023        );
2024
2025        assert!(matches!(
2026            results.as_slice(),
2027            [SseProcessResult::Usage(Usage {
2028                input_tokens: 42,
2029                output_tokens: 7,
2030                cached_input_tokens: 10,
2031                cache_creation_input_tokens: 0,
2032            })]
2033        ));
2034    }
2035
2036    #[test]
2037    fn test_api_usage_rejects_fractional_numbers() {
2038        let json = r#"{
2039            "prompt_tokens": 42.5,
2040            "completion_tokens": 7
2041        }"#;
2042
2043        let usage: std::result::Result<ApiUsage, _> = serde_json::from_str(json);
2044        assert!(usage.is_err());
2045    }
2046
2047    #[test]
2048    fn test_use_max_tokens_alias_for_vendor_urls() {
2049        assert!(!use_max_tokens_alias(DEFAULT_BASE_URL));
2050        assert!(use_max_tokens_alias(BASE_URL_KIMI));
2051        assert!(use_max_tokens_alias(BASE_URL_ZAI));
2052        assert!(use_max_tokens_alias(BASE_URL_MINIMAX));
2053    }
2054
2055    #[test]
2056    fn test_requires_responses_api_only_for_legacy_codex_model() {
2057        assert!(requires_responses_api(MODEL_GPT52_CODEX));
2058        assert!(!requires_responses_api(MODEL_GPT53_CODEX));
2059        assert!(!requires_responses_api(MODEL_GPT54));
2060    }
2061
2062    #[test]
2063    fn test_should_use_responses_api_for_official_agentic_requests() {
2064        let request = ChatRequest {
2065            system: String::new(),
2066            messages: vec![agent_sdk_foundation::llm::Message::user("Hello")],
2067            tools: Some(vec![agent_sdk_foundation::llm::Tool {
2068                name: "read_file".to_string(),
2069                description: "Read a file".to_string(),
2070                input_schema: serde_json::json!({"type": "object"}),
2071                display_name: "Read File".to_string(),
2072                tier: agent_sdk_foundation::ToolTier::Observe,
2073            }]),
2074            max_tokens: 1024,
2075            max_tokens_explicit: true,
2076            session_id: Some("thread-1".to_string()),
2077            cached_content: None,
2078            thinking: None,
2079            tool_choice: None,
2080            response_format: None,
2081        };
2082
2083        assert!(should_use_responses_api(
2084            DEFAULT_BASE_URL,
2085            MODEL_GPT54,
2086            &request
2087        ));
2088        assert!(!should_use_responses_api(
2089            BASE_URL_KIMI,
2090            MODEL_GPT54,
2091            &request
2092        ));
2093    }
2094
2095    #[test]
2096    fn test_build_api_reasoning_maps_enabled_budget_to_effort() {
2097        let reasoning = build_api_reasoning(Some(&ThinkingConfig::new(40_000))).unwrap();
2098        assert!(matches!(reasoning.effort, ReasoningEffort::XHigh));
2099    }
2100
2101    #[test]
2102    fn test_build_api_reasoning_uses_explicit_effort() {
2103        let reasoning =
2104            build_api_reasoning(Some(&ThinkingConfig::adaptive_with_effort(Effort::High))).unwrap();
2105        assert!(matches!(reasoning.effort, ReasoningEffort::High));
2106    }
2107
2108    #[test]
2109    fn test_build_api_reasoning_omits_adaptive_without_effort() {
2110        assert!(build_api_reasoning(Some(&ThinkingConfig::adaptive())).is_none());
2111    }
2112
2113    #[test]
2114    fn test_openai_rejects_adaptive_thinking() {
2115        let provider = OpenAIProvider::gpt54("test-key".to_string());
2116        let error = provider
2117            .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
2118            .unwrap_err();
2119        assert!(
2120            error
2121                .to_string()
2122                .contains("adaptive thinking is not supported")
2123        );
2124    }
2125
2126    #[test]
2127    fn test_openai_non_reasoning_models_reject_thinking() {
2128        let provider = OpenAIProvider::gpt4o("test-key".to_string());
2129        let error = provider
2130            .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
2131            .unwrap_err();
2132        assert!(error.to_string().contains("thinking is not supported"));
2133    }
2134
2135    #[test]
2136    fn test_request_serialization_openai_uses_max_completion_tokens_only() {
2137        let messages = vec![ApiMessage {
2138            role: ApiRole::User,
2139            content: Some("Hello".to_string()),
2140            tool_calls: None,
2141            tool_call_id: None,
2142        }];
2143
2144        let request = ApiChatRequest {
2145            model: "gpt-4o",
2146            messages: &messages,
2147            max_completion_tokens: Some(1024),
2148            max_tokens: None,
2149            tools: None,
2150            tool_choice: None,
2151            reasoning: None,
2152            response_format: None,
2153        };
2154
2155        let json = serde_json::to_string(&request).unwrap();
2156        assert!(json.contains("\"max_completion_tokens\":1024"));
2157        assert!(!json.contains("\"max_tokens\""));
2158    }
2159
2160    #[test]
2161    fn test_request_serialization_with_max_tokens_alias() {
2162        let messages = vec![ApiMessage {
2163            role: ApiRole::User,
2164            content: Some("Hello".to_string()),
2165            tool_calls: None,
2166            tool_call_id: None,
2167        }];
2168
2169        let request = ApiChatRequest {
2170            model: "glm-5",
2171            messages: &messages,
2172            max_completion_tokens: Some(1024),
2173            max_tokens: Some(1024),
2174            tools: None,
2175            tool_choice: None,
2176            reasoning: None,
2177            response_format: None,
2178        };
2179
2180        let json = serde_json::to_string(&request).unwrap();
2181        assert!(json.contains("\"max_completion_tokens\":1024"));
2182        assert!(json.contains("\"max_tokens\":1024"));
2183    }
2184
2185    #[test]
2186    fn test_streaming_request_serialization_openai_default() {
2187        let messages = vec![ApiMessage {
2188            role: ApiRole::User,
2189            content: Some("Hello".to_string()),
2190            tool_calls: None,
2191            tool_call_id: None,
2192        }];
2193
2194        let request = ApiChatRequestStreaming {
2195            model: "gpt-4o",
2196            messages: &messages,
2197            max_completion_tokens: Some(1024),
2198            max_tokens: None,
2199            tools: None,
2200            tool_choice: None,
2201            reasoning: None,
2202            response_format: None,
2203            stream_options: Some(ApiStreamOptions {
2204                include_usage: true,
2205            }),
2206            stream: true,
2207        };
2208
2209        let json = serde_json::to_string(&request).unwrap();
2210        assert!(json.contains("\"stream\":true"));
2211        assert!(json.contains("\"model\":\"gpt-4o\""));
2212        assert!(json.contains("\"max_completion_tokens\":1024"));
2213        assert!(json.contains("\"stream_options\":{\"include_usage\":true}"));
2214        assert!(!json.contains("\"max_tokens\""));
2215    }
2216
2217    #[test]
2218    fn test_streaming_request_serialization_with_max_tokens_alias() {
2219        let messages = vec![ApiMessage {
2220            role: ApiRole::User,
2221            content: Some("Hello".to_string()),
2222            tool_calls: None,
2223            tool_call_id: None,
2224        }];
2225
2226        let request = ApiChatRequestStreaming {
2227            model: "kimi-k2-thinking",
2228            messages: &messages,
2229            max_completion_tokens: Some(1024),
2230            max_tokens: Some(1024),
2231            tools: None,
2232            tool_choice: None,
2233            reasoning: None,
2234            response_format: None,
2235            stream_options: None,
2236            stream: true,
2237        };
2238
2239        let json = serde_json::to_string(&request).unwrap();
2240        assert!(json.contains("\"max_completion_tokens\":1024"));
2241        assert!(json.contains("\"max_tokens\":1024"));
2242        assert!(!json.contains("\"stream_options\""));
2243    }
2244
2245    #[test]
2246    fn test_request_serialization_includes_reasoning_when_present() {
2247        let messages = vec![ApiMessage {
2248            role: ApiRole::User,
2249            content: Some("Hello".to_string()),
2250            tool_calls: None,
2251            tool_call_id: None,
2252        }];
2253
2254        let request = ApiChatRequest {
2255            model: MODEL_GPT54,
2256            messages: &messages,
2257            max_completion_tokens: Some(1024),
2258            max_tokens: None,
2259            tools: None,
2260            tool_choice: None,
2261            reasoning: Some(ApiReasoning {
2262                effort: ReasoningEffort::High,
2263            }),
2264            response_format: None,
2265        };
2266
2267        let json = serde_json::to_string(&request).unwrap();
2268        assert!(json.contains("\"reasoning\":{\"effort\":\"high\"}"));
2269    }
2270
2271    #[test]
2272    fn test_response_format_serializes_as_json_schema() {
2273        let messages = vec![ApiMessage {
2274            role: ApiRole::User,
2275            content: Some("Hello".to_string()),
2276            tool_calls: None,
2277            tool_call_id: None,
2278        }];
2279
2280        let response_format = Some(ApiResponseFormat::from_response_format(
2281            &agent_sdk_foundation::llm::ResponseFormat::new(
2282                "person",
2283                serde_json::json!({"type": "object"}),
2284            ),
2285        ));
2286
2287        let request = ApiChatRequest {
2288            model: "gpt-4o",
2289            messages: &messages,
2290            max_completion_tokens: Some(1024),
2291            max_tokens: None,
2292            tools: None,
2293            tool_choice: None,
2294            reasoning: None,
2295            response_format,
2296        };
2297
2298        let json = serde_json::to_value(&request).unwrap();
2299        assert_eq!(json["response_format"]["type"], "json_schema");
2300        assert_eq!(json["response_format"]["json_schema"]["name"], "person");
2301        assert_eq!(json["response_format"]["json_schema"]["strict"], true);
2302        assert_eq!(
2303            json["response_format"]["json_schema"]["schema"]["type"],
2304            "object"
2305        );
2306    }
2307
2308    #[test]
2309    fn test_response_format_omitted_when_absent() {
2310        let messages = vec![ApiMessage {
2311            role: ApiRole::User,
2312            content: Some("Hello".to_string()),
2313            tool_calls: None,
2314            tool_call_id: None,
2315        }];
2316
2317        let request = ApiChatRequest {
2318            model: "gpt-4o",
2319            messages: &messages,
2320            max_completion_tokens: Some(1024),
2321            max_tokens: None,
2322            tools: None,
2323            tool_choice: None,
2324            reasoning: None,
2325            response_format: None,
2326        };
2327
2328        let json = serde_json::to_string(&request).unwrap();
2329        assert!(!json.contains("response_format"));
2330    }
2331}