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
960/// Non-empty reasoning text from an `OpenAI`-compatible response message, if any.
961///
962/// Prefers `DeepSeek`-style `reasoning_content`, falling back to the `reasoning`
963/// field used by some `OpenRouter` upstreams.
964fn reasoning_text(message: &ApiResponseMessage) -> Option<&str> {
965    message
966        .reasoning_content
967        .as_deref()
968        .or(message.reasoning.as_deref())
969        .filter(|r| !r.is_empty())
970}
971
972fn build_content_blocks(message: &ApiResponseMessage) -> Vec<ContentBlock> {
973    let mut blocks = Vec::new();
974
975    // Add text content if present
976    if let Some(content) = &message.content
977        && !content.is_empty()
978    {
979        blocks.push(ContentBlock::Text {
980            text: content.clone(),
981        });
982    } else if let Some(reasoning) = reasoning_text(message) {
983        // Reasoning-model fallback: when `content` is empty/absent but the model
984        // produced reasoning tokens (DeepSeek-style answer-in-`reasoning_content`,
985        // or any reasoning model truncated under a tight `max_tokens` before it
986        // emitted visible content), surface the reasoning as a Thinking block so
987        // the usable output is not silently dropped. This is a fallback only —
988        // when `content` is present the reasoning is left untouched.
989        blocks.push(ContentBlock::Thinking {
990            thinking: reasoning.to_owned(),
991            signature: None,
992        });
993    }
994
995    // Add tool calls if present
996    if let Some(tool_calls) = &message.tool_calls {
997        for tc in tool_calls {
998            let input: serde_json::Value = serde_json::from_str(&tc.function.arguments)
999                .unwrap_or_else(|_| serde_json::json!({}));
1000            blocks.push(ContentBlock::ToolUse {
1001                id: tc.id.clone(),
1002                name: tc.function.name.clone(),
1003                input,
1004                thought_signature: None,
1005            });
1006        }
1007    }
1008
1009    blocks
1010}
1011
1012// ============================================================================
1013// API Request Types
1014// ============================================================================
1015
1016#[derive(Serialize)]
1017struct ApiChatRequest<'a> {
1018    model: &'a str,
1019    messages: &'a [ApiMessage],
1020    #[serde(skip_serializing_if = "Option::is_none")]
1021    max_completion_tokens: Option<u32>,
1022    #[serde(skip_serializing_if = "Option::is_none")]
1023    max_tokens: Option<u32>,
1024    #[serde(skip_serializing_if = "Option::is_none")]
1025    tools: Option<&'a [ApiTool]>,
1026    #[serde(skip_serializing_if = "Option::is_none")]
1027    tool_choice: Option<ApiToolChoice>,
1028    #[serde(skip_serializing_if = "Option::is_none")]
1029    reasoning: Option<ApiReasoning>,
1030    #[serde(skip_serializing_if = "Option::is_none")]
1031    response_format: Option<ApiResponseFormat>,
1032}
1033
1034#[derive(Serialize)]
1035struct ApiChatRequestStreaming<'a> {
1036    model: &'a str,
1037    messages: &'a [ApiMessage],
1038    #[serde(skip_serializing_if = "Option::is_none")]
1039    max_completion_tokens: Option<u32>,
1040    #[serde(skip_serializing_if = "Option::is_none")]
1041    max_tokens: Option<u32>,
1042    #[serde(skip_serializing_if = "Option::is_none")]
1043    tools: Option<&'a [ApiTool]>,
1044    #[serde(skip_serializing_if = "Option::is_none")]
1045    tool_choice: Option<ApiToolChoice>,
1046    #[serde(skip_serializing_if = "Option::is_none")]
1047    reasoning: Option<ApiReasoning>,
1048    #[serde(skip_serializing_if = "Option::is_none")]
1049    response_format: Option<ApiResponseFormat>,
1050    #[serde(skip_serializing_if = "Option::is_none")]
1051    stream_options: Option<ApiStreamOptions>,
1052    stream: bool,
1053}
1054
1055/// `OpenAI` `tool_choice` wire format.
1056///
1057/// - `"auto"` — model decides.
1058/// - `{"type": "function", "function": {"name": "<name>"}}` — force a specific function.
1059#[derive(Serialize)]
1060#[serde(untagged)]
1061enum ApiToolChoice {
1062    String(String),
1063    Named {
1064        #[serde(rename = "type")]
1065        choice_type: String,
1066        function: ApiToolChoiceFunction,
1067    },
1068}
1069
1070#[derive(Serialize)]
1071struct ApiToolChoiceFunction {
1072    name: String,
1073}
1074
1075impl ApiToolChoice {
1076    fn from_tool_choice(tc: &agent_sdk_foundation::llm::ToolChoice) -> Self {
1077        match tc {
1078            agent_sdk_foundation::llm::ToolChoice::Auto => Self::String("auto".to_owned()),
1079            agent_sdk_foundation::llm::ToolChoice::Tool(name) => Self::Named {
1080                choice_type: "function".to_owned(),
1081                function: ApiToolChoiceFunction { name: name.clone() },
1082            },
1083        }
1084    }
1085}
1086
1087/// `OpenAI` `response_format` wire format for structured outputs.
1088///
1089/// Emits `{"type": "json_schema", "json_schema": {"name", "schema", "strict"}}`.
1090#[derive(Serialize)]
1091struct ApiResponseFormat {
1092    #[serde(rename = "type")]
1093    format_type: &'static str,
1094    json_schema: ApiJsonSchema,
1095}
1096
1097#[derive(Serialize)]
1098struct ApiJsonSchema {
1099    name: String,
1100    schema: serde_json::Value,
1101    strict: bool,
1102}
1103
1104impl ApiResponseFormat {
1105    fn from_response_format(rf: &agent_sdk_foundation::llm::ResponseFormat) -> Self {
1106        Self {
1107            format_type: "json_schema",
1108            json_schema: ApiJsonSchema {
1109                name: rf.name.clone(),
1110                schema: rf.schema.clone(),
1111                strict: rf.strict,
1112            },
1113        }
1114    }
1115}
1116
1117#[derive(Clone, Copy, Serialize)]
1118struct ApiStreamOptions {
1119    include_usage: bool,
1120}
1121
1122#[derive(Clone, Copy, Serialize)]
1123#[serde(rename_all = "lowercase")]
1124enum ReasoningEffort {
1125    Low,
1126    Medium,
1127    High,
1128    #[serde(rename = "xhigh")]
1129    XHigh,
1130}
1131
1132#[derive(Serialize)]
1133struct ApiReasoning {
1134    effort: ReasoningEffort,
1135}
1136
1137#[derive(Serialize)]
1138struct ApiMessage {
1139    role: ApiRole,
1140    #[serde(skip_serializing_if = "Option::is_none")]
1141    content: Option<String>,
1142    #[serde(skip_serializing_if = "Option::is_none")]
1143    tool_calls: Option<Vec<ApiToolCall>>,
1144    #[serde(skip_serializing_if = "Option::is_none")]
1145    tool_call_id: Option<String>,
1146}
1147
1148#[derive(Debug, Serialize, PartialEq, Eq)]
1149#[serde(rename_all = "lowercase")]
1150enum ApiRole {
1151    System,
1152    User,
1153    Assistant,
1154    Tool,
1155}
1156
1157#[derive(Serialize)]
1158struct ApiToolCall {
1159    id: String,
1160    r#type: String,
1161    function: ApiFunctionCall,
1162}
1163
1164#[derive(Serialize)]
1165struct ApiFunctionCall {
1166    name: String,
1167    arguments: String,
1168}
1169
1170#[derive(Serialize)]
1171struct ApiTool {
1172    r#type: String,
1173    function: ApiFunction,
1174}
1175
1176#[derive(Serialize)]
1177struct ApiFunction {
1178    name: String,
1179    description: String,
1180    parameters: serde_json::Value,
1181}
1182
1183// ============================================================================
1184// API Response Types
1185// ============================================================================
1186
1187#[derive(Deserialize)]
1188struct ApiChatResponse {
1189    id: String,
1190    choices: Vec<ApiChoice>,
1191    model: String,
1192    usage: ApiUsage,
1193}
1194
1195#[derive(Deserialize)]
1196struct ApiChoice {
1197    message: ApiResponseMessage,
1198    finish_reason: Option<String>,
1199}
1200
1201#[derive(Deserialize)]
1202struct ApiResponseMessage {
1203    content: Option<String>,
1204    tool_calls: Option<Vec<ApiResponseToolCall>>,
1205    /// `DeepSeek`-style chain-of-thought, returned at the same level as
1206    /// `content` (`DeepSeek` V4 / some `OpenRouter` providers).
1207    #[serde(default)]
1208    reasoning_content: Option<String>,
1209    /// `OpenRouter` normalizes reasoning under a `reasoning` field for some
1210    /// upstreams; treated as an equivalent fallback to `reasoning_content`.
1211    #[serde(default)]
1212    reasoning: Option<String>,
1213}
1214
1215#[derive(Deserialize)]
1216struct ApiResponseToolCall {
1217    id: String,
1218    function: ApiResponseFunctionCall,
1219}
1220
1221#[derive(Deserialize)]
1222struct ApiResponseFunctionCall {
1223    name: String,
1224    arguments: String,
1225}
1226
1227#[derive(Deserialize)]
1228struct ApiUsage {
1229    #[serde(deserialize_with = "deserialize_u32_from_number")]
1230    prompt_tokens: u32,
1231    #[serde(deserialize_with = "deserialize_u32_from_number")]
1232    completion_tokens: u32,
1233    #[serde(default)]
1234    prompt_tokens_details: Option<ApiPromptTokensDetails>,
1235}
1236
1237#[derive(Deserialize)]
1238struct ApiPromptTokensDetails {
1239    #[serde(default, deserialize_with = "deserialize_u32_from_number")]
1240    cached_tokens: u32,
1241}
1242
1243// ============================================================================
1244// SSE Streaming Types
1245// ============================================================================
1246
1247/// Accumulator for tool call state across stream deltas.
1248struct ToolCallAccumulator {
1249    id: String,
1250    name: String,
1251    arguments: String,
1252}
1253
1254/// A single chunk in `OpenAI`'s SSE stream.
1255#[derive(Deserialize)]
1256struct SseChunk {
1257    choices: Vec<SseChoice>,
1258    #[serde(default)]
1259    usage: Option<SseUsage>,
1260}
1261
1262#[derive(Deserialize)]
1263struct SseChoice {
1264    delta: SseDelta,
1265    finish_reason: Option<String>,
1266}
1267
1268#[derive(Deserialize)]
1269struct SseDelta {
1270    content: Option<String>,
1271    tool_calls: Option<Vec<SseToolCallDelta>>,
1272}
1273
1274#[derive(Deserialize)]
1275struct SseToolCallDelta {
1276    index: usize,
1277    id: Option<String>,
1278    function: Option<SseFunctionDelta>,
1279}
1280
1281#[derive(Deserialize)]
1282struct SseFunctionDelta {
1283    name: Option<String>,
1284    arguments: Option<String>,
1285}
1286
1287#[derive(Deserialize)]
1288struct SseUsage {
1289    #[serde(deserialize_with = "deserialize_u32_from_number")]
1290    prompt_tokens: u32,
1291    #[serde(deserialize_with = "deserialize_u32_from_number")]
1292    completion_tokens: u32,
1293    #[serde(default)]
1294    prompt_tokens_details: Option<ApiPromptTokensDetails>,
1295}
1296
1297fn deserialize_u32_from_number<'de, D>(deserializer: D) -> std::result::Result<u32, D::Error>
1298where
1299    D: serde::Deserializer<'de>,
1300{
1301    #[derive(Deserialize)]
1302    #[serde(untagged)]
1303    enum NumberLike {
1304        U64(u64),
1305        F64(f64),
1306    }
1307
1308    match NumberLike::deserialize(deserializer)? {
1309        NumberLike::U64(v) => u32::try_from(v)
1310            .map_err(|_| D::Error::custom(format!("token count out of range for u32: {v}"))),
1311        NumberLike::F64(v) => {
1312            if v.is_finite() && v >= 0.0 && v.fract() == 0.0 && v <= f64::from(u32::MAX) {
1313                v.to_string().parse::<u32>().map_err(|e| {
1314                    D::Error::custom(format!(
1315                        "failed to convert integer-compatible token count {v} to u32: {e}"
1316                    ))
1317                })
1318            } else {
1319                Err(D::Error::custom(format!(
1320                    "token count must be a non-negative integer-compatible number, got {v}"
1321                )))
1322            }
1323        }
1324    }
1325}
1326
1327#[cfg(test)]
1328mod tests {
1329    use super::*;
1330
1331    // ===================
1332    // Constructor Tests
1333    // ===================
1334
1335    #[test]
1336    fn test_new_creates_provider_with_custom_model() {
1337        let provider = OpenAIProvider::new("test-api-key".to_string(), "custom-model".to_string());
1338
1339        assert_eq!(provider.model(), "custom-model");
1340        assert_eq!(provider.provider(), "openai");
1341        assert_eq!(provider.base_url, DEFAULT_BASE_URL);
1342    }
1343
1344    #[test]
1345    fn test_with_base_url_creates_provider_with_custom_url() {
1346        let provider = OpenAIProvider::with_base_url(
1347            "test-api-key".to_string(),
1348            "llama3".to_string(),
1349            "http://localhost:11434/v1".to_string(),
1350        );
1351
1352        assert_eq!(provider.model(), "llama3");
1353        assert_eq!(provider.base_url, "http://localhost:11434/v1");
1354    }
1355
1356    #[test]
1357    fn test_gpt4o_factory_creates_gpt4o_provider() {
1358        let provider = OpenAIProvider::gpt4o("test-api-key".to_string());
1359
1360        assert_eq!(provider.model(), MODEL_GPT4O);
1361        assert_eq!(provider.provider(), "openai");
1362    }
1363
1364    #[test]
1365    fn test_gpt4o_mini_factory_creates_gpt4o_mini_provider() {
1366        let provider = OpenAIProvider::gpt4o_mini("test-api-key".to_string());
1367
1368        assert_eq!(provider.model(), MODEL_GPT4O_MINI);
1369        assert_eq!(provider.provider(), "openai");
1370    }
1371
1372    #[test]
1373    fn test_gpt52_thinking_factory_creates_provider() {
1374        let provider = OpenAIProvider::gpt52_thinking("test-api-key".to_string());
1375
1376        assert_eq!(provider.model(), MODEL_GPT52_THINKING);
1377        assert_eq!(provider.provider(), "openai");
1378    }
1379
1380    #[test]
1381    fn test_gpt54_factory_creates_provider() {
1382        let provider = OpenAIProvider::gpt54("test-api-key".to_string());
1383
1384        assert_eq!(provider.model(), MODEL_GPT54);
1385        assert_eq!(provider.provider(), "openai");
1386    }
1387
1388    #[test]
1389    fn test_gpt53_codex_factory_creates_provider() {
1390        let provider = OpenAIProvider::gpt53_codex("test-api-key".to_string());
1391
1392        assert_eq!(provider.model(), MODEL_GPT53_CODEX);
1393        assert_eq!(provider.provider(), "openai");
1394    }
1395
1396    #[test]
1397    fn test_codex_factory_points_to_latest_codex_model() {
1398        let provider = OpenAIProvider::codex("test-api-key".to_string());
1399
1400        assert_eq!(provider.model(), MODEL_GPT53_CODEX);
1401        assert_eq!(provider.provider(), "openai");
1402    }
1403
1404    #[test]
1405    fn test_gpt5_factory_creates_gpt5_provider() {
1406        let provider = OpenAIProvider::gpt5("test-api-key".to_string());
1407
1408        assert_eq!(provider.model(), MODEL_GPT5);
1409        assert_eq!(provider.provider(), "openai");
1410    }
1411
1412    #[test]
1413    fn test_gpt5_mini_factory_creates_provider() {
1414        let provider = OpenAIProvider::gpt5_mini("test-api-key".to_string());
1415
1416        assert_eq!(provider.model(), MODEL_GPT5_MINI);
1417        assert_eq!(provider.provider(), "openai");
1418    }
1419
1420    #[test]
1421    fn test_o3_factory_creates_o3_provider() {
1422        let provider = OpenAIProvider::o3("test-api-key".to_string());
1423
1424        assert_eq!(provider.model(), MODEL_O3);
1425        assert_eq!(provider.provider(), "openai");
1426    }
1427
1428    #[test]
1429    fn test_o4_mini_factory_creates_o4_mini_provider() {
1430        let provider = OpenAIProvider::o4_mini("test-api-key".to_string());
1431
1432        assert_eq!(provider.model(), MODEL_O4_MINI);
1433        assert_eq!(provider.provider(), "openai");
1434    }
1435
1436    #[test]
1437    fn test_o1_factory_creates_o1_provider() {
1438        let provider = OpenAIProvider::o1("test-api-key".to_string());
1439
1440        assert_eq!(provider.model(), MODEL_O1);
1441        assert_eq!(provider.provider(), "openai");
1442    }
1443
1444    #[test]
1445    fn test_gpt41_factory_creates_gpt41_provider() {
1446        let provider = OpenAIProvider::gpt41("test-api-key".to_string());
1447
1448        assert_eq!(provider.model(), MODEL_GPT41);
1449        assert_eq!(provider.provider(), "openai");
1450    }
1451
1452    #[test]
1453    fn test_kimi_factory_creates_provider_with_kimi_base_url() {
1454        let provider = OpenAIProvider::kimi("test-api-key".to_string(), "kimi-custom".to_string());
1455
1456        assert_eq!(provider.model(), "kimi-custom");
1457        assert_eq!(provider.base_url, BASE_URL_KIMI);
1458        assert_eq!(provider.provider(), "openai");
1459    }
1460
1461    #[test]
1462    fn test_kimi_k2_5_factory_creates_provider() {
1463        let provider = OpenAIProvider::kimi_k2_5("test-api-key".to_string());
1464
1465        assert_eq!(provider.model(), MODEL_KIMI_K2_5);
1466        assert_eq!(provider.base_url, BASE_URL_KIMI);
1467        assert_eq!(provider.provider(), "openai");
1468    }
1469
1470    #[test]
1471    fn test_kimi_k2_thinking_factory_creates_provider() {
1472        let provider = OpenAIProvider::kimi_k2_thinking("test-api-key".to_string());
1473
1474        assert_eq!(provider.model(), MODEL_KIMI_K2_THINKING);
1475        assert_eq!(provider.base_url, BASE_URL_KIMI);
1476        assert_eq!(provider.provider(), "openai");
1477    }
1478
1479    #[test]
1480    fn test_zai_factory_creates_provider_with_zai_base_url() {
1481        let provider = OpenAIProvider::zai("test-api-key".to_string(), "glm-custom".to_string());
1482
1483        assert_eq!(provider.model(), "glm-custom");
1484        assert_eq!(provider.base_url, BASE_URL_ZAI);
1485        assert_eq!(provider.provider(), "openai");
1486    }
1487
1488    #[test]
1489    fn test_zai_glm5_factory_creates_provider() {
1490        let provider = OpenAIProvider::zai_glm5("test-api-key".to_string());
1491
1492        assert_eq!(provider.model(), MODEL_ZAI_GLM5);
1493        assert_eq!(provider.base_url, BASE_URL_ZAI);
1494        assert_eq!(provider.provider(), "openai");
1495    }
1496
1497    #[test]
1498    fn test_minimax_factory_creates_provider_with_minimax_base_url() {
1499        let provider =
1500            OpenAIProvider::minimax("test-api-key".to_string(), "minimax-custom".to_string());
1501
1502        assert_eq!(provider.model(), "minimax-custom");
1503        assert_eq!(provider.base_url, BASE_URL_MINIMAX);
1504        assert_eq!(provider.provider(), "openai");
1505    }
1506
1507    #[test]
1508    fn test_minimax_m2_5_factory_creates_provider() {
1509        let provider = OpenAIProvider::minimax_m2_5("test-api-key".to_string());
1510
1511        assert_eq!(provider.model(), MODEL_MINIMAX_M2_5);
1512        assert_eq!(provider.base_url, BASE_URL_MINIMAX);
1513        assert_eq!(provider.provider(), "openai");
1514    }
1515
1516    // ===================
1517    // Model Constants Tests
1518    // ===================
1519
1520    #[test]
1521    fn test_model_constants_have_expected_values() {
1522        // GPT-5.4 / GPT-5.3 Codex
1523        assert_eq!(MODEL_GPT54, "gpt-5.4");
1524        assert_eq!(MODEL_GPT53_CODEX, "gpt-5.3-codex");
1525        // GPT-5.2 series
1526        assert_eq!(MODEL_GPT52_INSTANT, "gpt-5.2-instant");
1527        assert_eq!(MODEL_GPT52_THINKING, "gpt-5.2-thinking");
1528        assert_eq!(MODEL_GPT52_PRO, "gpt-5.2-pro");
1529        assert_eq!(MODEL_GPT52_CODEX, "gpt-5.2-codex");
1530        // GPT-5 series
1531        assert_eq!(MODEL_GPT5, "gpt-5");
1532        assert_eq!(MODEL_GPT5_MINI, "gpt-5-mini");
1533        assert_eq!(MODEL_GPT5_NANO, "gpt-5-nano");
1534        // o-series
1535        assert_eq!(MODEL_O3, "o3");
1536        assert_eq!(MODEL_O3_MINI, "o3-mini");
1537        assert_eq!(MODEL_O4_MINI, "o4-mini");
1538        assert_eq!(MODEL_O1, "o1");
1539        assert_eq!(MODEL_O1_MINI, "o1-mini");
1540        // GPT-4.1 series
1541        assert_eq!(MODEL_GPT41, "gpt-4.1");
1542        assert_eq!(MODEL_GPT41_MINI, "gpt-4.1-mini");
1543        assert_eq!(MODEL_GPT41_NANO, "gpt-4.1-nano");
1544        // GPT-4o series
1545        assert_eq!(MODEL_GPT4O, "gpt-4o");
1546        assert_eq!(MODEL_GPT4O_MINI, "gpt-4o-mini");
1547        // OpenAI-compatible vendor defaults
1548        assert_eq!(MODEL_KIMI_K2_5, "kimi-k2.5");
1549        assert_eq!(MODEL_KIMI_K2_THINKING, "kimi-k2-thinking");
1550        assert_eq!(MODEL_ZAI_GLM5, "glm-5");
1551        assert_eq!(MODEL_MINIMAX_M2_5, "MiniMax-M2.5");
1552        assert_eq!(BASE_URL_KIMI, "https://api.moonshot.ai/v1");
1553        assert_eq!(BASE_URL_ZAI, "https://api.z.ai/api/paas/v4");
1554        assert_eq!(BASE_URL_MINIMAX, "https://api.minimax.io/v1");
1555    }
1556
1557    // ===================
1558    // Clone Tests
1559    // ===================
1560
1561    #[test]
1562    fn test_provider_is_cloneable() {
1563        let provider = OpenAIProvider::new("test-api-key".to_string(), "test-model".to_string());
1564        let cloned = provider.clone();
1565
1566        assert_eq!(provider.model(), cloned.model());
1567        assert_eq!(provider.provider(), cloned.provider());
1568        assert_eq!(provider.base_url, cloned.base_url);
1569    }
1570
1571    // ===================
1572    // API Type Serialization Tests
1573    // ===================
1574
1575    #[test]
1576    fn test_api_role_serialization() {
1577        let system_role = ApiRole::System;
1578        let user_role = ApiRole::User;
1579        let assistant_role = ApiRole::Assistant;
1580        let tool_role = ApiRole::Tool;
1581
1582        assert_eq!(serde_json::to_string(&system_role).unwrap(), "\"system\"");
1583        assert_eq!(serde_json::to_string(&user_role).unwrap(), "\"user\"");
1584        assert_eq!(
1585            serde_json::to_string(&assistant_role).unwrap(),
1586            "\"assistant\""
1587        );
1588        assert_eq!(serde_json::to_string(&tool_role).unwrap(), "\"tool\"");
1589    }
1590
1591    #[test]
1592    fn test_api_message_serialization_simple() {
1593        let message = ApiMessage {
1594            role: ApiRole::User,
1595            content: Some("Hello, world!".to_string()),
1596            tool_calls: None,
1597            tool_call_id: None,
1598        };
1599
1600        let json = serde_json::to_string(&message).unwrap();
1601        assert!(json.contains("\"role\":\"user\""));
1602        assert!(json.contains("\"content\":\"Hello, world!\""));
1603        // Optional fields should be omitted
1604        assert!(!json.contains("tool_calls"));
1605        assert!(!json.contains("tool_call_id"));
1606    }
1607
1608    #[test]
1609    fn test_api_message_serialization_with_tool_calls() {
1610        let message = ApiMessage {
1611            role: ApiRole::Assistant,
1612            content: Some("Let me help.".to_string()),
1613            tool_calls: Some(vec![ApiToolCall {
1614                id: "call_123".to_string(),
1615                r#type: "function".to_string(),
1616                function: ApiFunctionCall {
1617                    name: "read_file".to_string(),
1618                    arguments: "{\"path\": \"/test.txt\"}".to_string(),
1619                },
1620            }]),
1621            tool_call_id: None,
1622        };
1623
1624        let json = serde_json::to_string(&message).unwrap();
1625        assert!(json.contains("\"role\":\"assistant\""));
1626        assert!(json.contains("\"tool_calls\""));
1627        assert!(json.contains("\"id\":\"call_123\""));
1628        assert!(json.contains("\"type\":\"function\""));
1629        assert!(json.contains("\"name\":\"read_file\""));
1630    }
1631
1632    #[test]
1633    fn test_api_tool_message_serialization() {
1634        let message = ApiMessage {
1635            role: ApiRole::Tool,
1636            content: Some("File contents here".to_string()),
1637            tool_calls: None,
1638            tool_call_id: Some("call_123".to_string()),
1639        };
1640
1641        let json = serde_json::to_string(&message).unwrap();
1642        assert!(json.contains("\"role\":\"tool\""));
1643        assert!(json.contains("\"tool_call_id\":\"call_123\""));
1644        assert!(json.contains("\"content\":\"File contents here\""));
1645    }
1646
1647    #[test]
1648    fn test_api_tool_serialization() {
1649        let tool = ApiTool {
1650            r#type: "function".to_string(),
1651            function: ApiFunction {
1652                name: "test_tool".to_string(),
1653                description: "A test tool".to_string(),
1654                parameters: serde_json::json!({
1655                    "type": "object",
1656                    "properties": {
1657                        "arg": {"type": "string"}
1658                    }
1659                }),
1660            },
1661        };
1662
1663        let json = serde_json::to_string(&tool).unwrap();
1664        assert!(json.contains("\"type\":\"function\""));
1665        assert!(json.contains("\"name\":\"test_tool\""));
1666        assert!(json.contains("\"description\":\"A test tool\""));
1667        assert!(json.contains("\"parameters\""));
1668    }
1669
1670    // ===================
1671    // API Type Deserialization Tests
1672    // ===================
1673
1674    #[test]
1675    fn test_api_response_deserialization() {
1676        let json = r#"{
1677            "id": "chatcmpl-123",
1678            "choices": [
1679                {
1680                    "message": {
1681                        "content": "Hello!"
1682                    },
1683                    "finish_reason": "stop"
1684                }
1685            ],
1686            "model": "gpt-4o",
1687            "usage": {
1688                "prompt_tokens": 100,
1689                "completion_tokens": 50
1690            }
1691        }"#;
1692
1693        let response: ApiChatResponse = serde_json::from_str(json).unwrap();
1694        assert_eq!(response.id, "chatcmpl-123");
1695        assert_eq!(response.model, "gpt-4o");
1696        assert_eq!(response.usage.prompt_tokens, 100);
1697        assert_eq!(response.usage.completion_tokens, 50);
1698        assert_eq!(response.choices.len(), 1);
1699        assert_eq!(
1700            response.choices[0].message.content,
1701            Some("Hello!".to_string())
1702        );
1703    }
1704
1705    #[test]
1706    fn test_api_response_with_tool_calls_deserialization() {
1707        let json = r#"{
1708            "id": "chatcmpl-456",
1709            "choices": [
1710                {
1711                    "message": {
1712                        "content": null,
1713                        "tool_calls": [
1714                            {
1715                                "id": "call_abc",
1716                                "type": "function",
1717                                "function": {
1718                                    "name": "read_file",
1719                                    "arguments": "{\"path\": \"test.txt\"}"
1720                                }
1721                            }
1722                        ]
1723                    },
1724                    "finish_reason": "tool_calls"
1725                }
1726            ],
1727            "model": "gpt-4o",
1728            "usage": {
1729                "prompt_tokens": 150,
1730                "completion_tokens": 30
1731            }
1732        }"#;
1733
1734        let response: ApiChatResponse = serde_json::from_str(json).unwrap();
1735        let tool_calls = response.choices[0].message.tool_calls.as_ref().unwrap();
1736        assert_eq!(tool_calls.len(), 1);
1737        assert_eq!(tool_calls[0].id, "call_abc");
1738        assert_eq!(tool_calls[0].function.name, "read_file");
1739    }
1740
1741    #[test]
1742    fn test_api_response_with_unknown_finish_reason_deserialization() {
1743        let json = r#"{
1744            "id": "chatcmpl-789",
1745            "choices": [
1746                {
1747                    "message": {
1748                        "content": "ok"
1749                    },
1750                    "finish_reason": "vendor_custom_reason"
1751                }
1752            ],
1753            "model": "glm-5",
1754            "usage": {
1755                "prompt_tokens": 10,
1756                "completion_tokens": 5
1757            }
1758        }"#;
1759
1760        let response: ApiChatResponse = serde_json::from_str(json).unwrap();
1761        assert_eq!(
1762            response.choices[0].finish_reason.as_deref(),
1763            Some("vendor_custom_reason")
1764        );
1765        assert_eq!(
1766            map_finish_reason(response.choices[0].finish_reason.as_deref().unwrap()),
1767            StopReason::StopSequence
1768        );
1769    }
1770
1771    #[test]
1772    fn test_map_finish_reason_covers_vendor_specific_values() {
1773        assert_eq!(map_finish_reason("stop"), StopReason::EndTurn);
1774        assert_eq!(map_finish_reason("tool_calls"), StopReason::ToolUse);
1775        assert_eq!(map_finish_reason("length"), StopReason::MaxTokens);
1776        assert_eq!(
1777            map_finish_reason("content_filter"),
1778            StopReason::StopSequence
1779        );
1780        assert_eq!(map_finish_reason("sensitive"), StopReason::Refusal);
1781        assert_eq!(map_finish_reason("network_error"), StopReason::StopSequence);
1782        assert_eq!(
1783            map_finish_reason("some_new_reason"),
1784            StopReason::StopSequence
1785        );
1786    }
1787
1788    // ===================
1789    // Message Conversion Tests
1790    // ===================
1791
1792    #[test]
1793    fn test_build_api_messages_with_system() {
1794        let request = ChatRequest {
1795            system: "You are helpful.".to_string(),
1796            messages: vec![agent_sdk_foundation::llm::Message::user("Hello")],
1797            tools: None,
1798            max_tokens: 1024,
1799            max_tokens_explicit: true,
1800            session_id: None,
1801            cached_content: None,
1802            thinking: None,
1803            tool_choice: None,
1804            response_format: None,
1805        };
1806
1807        let api_messages = build_api_messages(&request);
1808        assert_eq!(api_messages.len(), 2);
1809        assert_eq!(api_messages[0].role, ApiRole::System);
1810        assert_eq!(
1811            api_messages[0].content,
1812            Some("You are helpful.".to_string())
1813        );
1814        assert_eq!(api_messages[1].role, ApiRole::User);
1815        assert_eq!(api_messages[1].content, Some("Hello".to_string()));
1816    }
1817
1818    #[test]
1819    fn test_build_api_messages_empty_system() {
1820        let request = ChatRequest {
1821            system: String::new(),
1822            messages: vec![agent_sdk_foundation::llm::Message::user("Hello")],
1823            tools: None,
1824            max_tokens: 1024,
1825            max_tokens_explicit: true,
1826            session_id: None,
1827            cached_content: None,
1828            thinking: None,
1829            tool_choice: None,
1830            response_format: None,
1831        };
1832
1833        let api_messages = build_api_messages(&request);
1834        assert_eq!(api_messages.len(), 1);
1835        assert_eq!(api_messages[0].role, ApiRole::User);
1836    }
1837
1838    #[test]
1839    fn test_convert_tool() {
1840        let tool = agent_sdk_foundation::llm::Tool {
1841            name: "test_tool".to_string(),
1842            description: "A test tool".to_string(),
1843            input_schema: serde_json::json!({"type": "object"}),
1844            display_name: "Test Tool".to_string(),
1845            tier: agent_sdk_foundation::ToolTier::Observe,
1846        };
1847
1848        let api_tool = convert_tool(tool);
1849        assert_eq!(api_tool.r#type, "function");
1850        assert_eq!(api_tool.function.name, "test_tool");
1851        assert_eq!(api_tool.function.description, "A test tool");
1852    }
1853
1854    #[test]
1855    fn test_build_content_blocks_text_only() {
1856        let message = ApiResponseMessage {
1857            content: Some("Hello!".to_string()),
1858            tool_calls: None,
1859            reasoning_content: None,
1860            reasoning: None,
1861        };
1862
1863        let blocks = build_content_blocks(&message);
1864        assert_eq!(blocks.len(), 1);
1865        assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Hello!"));
1866    }
1867
1868    #[test]
1869    fn test_build_content_blocks_with_tool_calls() {
1870        let message = ApiResponseMessage {
1871            content: Some("Let me help.".to_string()),
1872            tool_calls: Some(vec![ApiResponseToolCall {
1873                id: "call_123".to_string(),
1874                function: ApiResponseFunctionCall {
1875                    name: "read_file".to_string(),
1876                    arguments: "{\"path\": \"test.txt\"}".to_string(),
1877                },
1878            }]),
1879            reasoning_content: None,
1880            reasoning: None,
1881        };
1882
1883        let blocks = build_content_blocks(&message);
1884        assert_eq!(blocks.len(), 2);
1885        assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Let me help."));
1886        assert!(
1887            matches!(&blocks[1], ContentBlock::ToolUse { id, name, .. } if id == "call_123" && name == "read_file")
1888        );
1889    }
1890
1891    #[test]
1892    fn test_build_content_blocks_falls_back_to_reasoning_content_when_content_empty() {
1893        // DeepSeek-style: answer / usable output arrives in reasoning_content
1894        // while content is null. Without the fallback this dropped all output.
1895        let message = ApiResponseMessage {
1896            content: None,
1897            tool_calls: None,
1898            reasoning_content: Some("The answer is 42.".to_string()),
1899            reasoning: None,
1900        };
1901
1902        let blocks = build_content_blocks(&message);
1903        assert_eq!(blocks.len(), 1);
1904        assert!(
1905            matches!(&blocks[0], ContentBlock::Thinking { thinking, signature } if thinking == "The answer is 42." && signature.is_none())
1906        );
1907    }
1908
1909    #[test]
1910    fn test_build_content_blocks_falls_back_to_reasoning_field() {
1911        // Some OpenRouter upstreams normalize reasoning under `reasoning`.
1912        let message = ApiResponseMessage {
1913            content: Some(String::new()),
1914            tool_calls: None,
1915            reasoning_content: None,
1916            reasoning: Some("Considering options...".to_string()),
1917        };
1918
1919        let blocks = build_content_blocks(&message);
1920        assert_eq!(blocks.len(), 1);
1921        assert!(
1922            matches!(&blocks[0], ContentBlock::Thinking { thinking, .. } if thinking == "Considering options...")
1923        );
1924    }
1925
1926    #[test]
1927    fn test_build_content_blocks_prefers_reasoning_content_over_reasoning() {
1928        let message = ApiResponseMessage {
1929            content: None,
1930            tool_calls: None,
1931            reasoning_content: Some("primary".to_string()),
1932            reasoning: Some("secondary".to_string()),
1933        };
1934
1935        let blocks = build_content_blocks(&message);
1936        assert_eq!(blocks.len(), 1);
1937        assert!(
1938            matches!(&blocks[0], ContentBlock::Thinking { thinking, .. } if thinking == "primary")
1939        );
1940    }
1941
1942    #[test]
1943    fn test_build_content_blocks_does_not_add_reasoning_when_content_present() {
1944        // The normal content-present case must be unchanged: reasoning is NOT
1945        // surfaced as a Thinking block when there is usable text content.
1946        let message = ApiResponseMessage {
1947            content: Some("Final answer.".to_string()),
1948            tool_calls: None,
1949            reasoning_content: Some("internal chain of thought".to_string()),
1950            reasoning: None,
1951        };
1952
1953        let blocks = build_content_blocks(&message);
1954        assert_eq!(blocks.len(), 1);
1955        assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "Final answer."));
1956    }
1957
1958    #[test]
1959    fn test_build_content_blocks_reasoning_fallback_with_tool_calls() {
1960        // Empty content + reasoning + a tool call: surface the reasoning AND the
1961        // tool call (reasoning model under tight max_tokens that still tool-called).
1962        let message = ApiResponseMessage {
1963            content: None,
1964            tool_calls: Some(vec![ApiResponseToolCall {
1965                id: "call_1".to_string(),
1966                function: ApiResponseFunctionCall {
1967                    name: "search".to_string(),
1968                    arguments: "{}".to_string(),
1969                },
1970            }]),
1971            reasoning_content: Some("I should search.".to_string()),
1972            reasoning: None,
1973        };
1974
1975        let blocks = build_content_blocks(&message);
1976        assert_eq!(blocks.len(), 2);
1977        assert!(
1978            matches!(&blocks[0], ContentBlock::Thinking { thinking, .. } if thinking == "I should search.")
1979        );
1980        assert!(matches!(&blocks[1], ContentBlock::ToolUse { name, .. } if name == "search"));
1981    }
1982
1983    #[test]
1984    fn test_build_content_blocks_empty_message_yields_no_blocks() {
1985        // Genuine truncation with no reasoning text: still produce nothing
1986        // (behavior unchanged for the empty case).
1987        let message = ApiResponseMessage {
1988            content: None,
1989            tool_calls: None,
1990            reasoning_content: None,
1991            reasoning: None,
1992        };
1993
1994        let blocks = build_content_blocks(&message);
1995        assert!(blocks.is_empty());
1996    }
1997
1998    #[test]
1999    fn test_api_response_message_deserializes_reasoning_content() {
2000        let json = r#"{
2001            "content": null,
2002            "reasoning_content": "step by step"
2003        }"#;
2004
2005        let message: ApiResponseMessage = serde_json::from_str(json).unwrap();
2006        assert_eq!(reasoning_text(&message), Some("step by step"));
2007        assert!(message.content.is_none());
2008    }
2009
2010    // ===================
2011    // SSE Streaming Type Tests
2012    // ===================
2013
2014    #[test]
2015    fn test_sse_chunk_text_delta_deserialization() {
2016        let json = r#"{
2017            "choices": [{
2018                "delta": {
2019                    "content": "Hello"
2020                },
2021                "finish_reason": null
2022            }]
2023        }"#;
2024
2025        let chunk: SseChunk = serde_json::from_str(json).unwrap();
2026        assert_eq!(chunk.choices.len(), 1);
2027        assert_eq!(chunk.choices[0].delta.content, Some("Hello".to_string()));
2028        assert!(chunk.choices[0].finish_reason.is_none());
2029    }
2030
2031    #[test]
2032    fn test_sse_chunk_tool_call_delta_deserialization() {
2033        let json = r#"{
2034            "choices": [{
2035                "delta": {
2036                    "tool_calls": [{
2037                        "index": 0,
2038                        "id": "call_abc",
2039                        "function": {
2040                            "name": "read_file",
2041                            "arguments": ""
2042                        }
2043                    }]
2044                },
2045                "finish_reason": null
2046            }]
2047        }"#;
2048
2049        let chunk: SseChunk = serde_json::from_str(json).unwrap();
2050        let tool_calls = chunk.choices[0].delta.tool_calls.as_ref().unwrap();
2051        assert_eq!(tool_calls.len(), 1);
2052        assert_eq!(tool_calls[0].index, 0);
2053        assert_eq!(tool_calls[0].id, Some("call_abc".to_string()));
2054        assert_eq!(
2055            tool_calls[0].function.as_ref().unwrap().name,
2056            Some("read_file".to_string())
2057        );
2058    }
2059
2060    #[test]
2061    fn test_sse_chunk_tool_call_arguments_delta_deserialization() {
2062        let json = r#"{
2063            "choices": [{
2064                "delta": {
2065                    "tool_calls": [{
2066                        "index": 0,
2067                        "function": {
2068                            "arguments": "{\"path\":"
2069                        }
2070                    }]
2071                },
2072                "finish_reason": null
2073            }]
2074        }"#;
2075
2076        let chunk: SseChunk = serde_json::from_str(json).unwrap();
2077        let tool_calls = chunk.choices[0].delta.tool_calls.as_ref().unwrap();
2078        assert_eq!(tool_calls[0].id, None);
2079        assert_eq!(
2080            tool_calls[0].function.as_ref().unwrap().arguments,
2081            Some("{\"path\":".to_string())
2082        );
2083    }
2084
2085    #[test]
2086    fn test_sse_chunk_with_finish_reason_deserialization() {
2087        let json = r#"{
2088            "choices": [{
2089                "delta": {},
2090                "finish_reason": "stop"
2091            }]
2092        }"#;
2093
2094        let chunk: SseChunk = serde_json::from_str(json).unwrap();
2095        assert_eq!(chunk.choices[0].finish_reason.as_deref(), Some("stop"));
2096    }
2097
2098    #[test]
2099    fn test_sse_chunk_with_usage_deserialization() {
2100        let json = r#"{
2101            "choices": [{
2102                "delta": {},
2103                "finish_reason": "stop"
2104            }],
2105            "usage": {
2106                "prompt_tokens": 100,
2107                "completion_tokens": 50
2108            }
2109        }"#;
2110
2111        let chunk: SseChunk = serde_json::from_str(json).unwrap();
2112        let usage = chunk.usage.unwrap();
2113        assert_eq!(usage.prompt_tokens, 100);
2114        assert_eq!(usage.completion_tokens, 50);
2115    }
2116
2117    #[test]
2118    fn test_sse_chunk_with_float_usage_deserialization() {
2119        let json = r#"{
2120            "choices": [{
2121                "delta": {},
2122                "finish_reason": "stop"
2123            }],
2124            "usage": {
2125                "prompt_tokens": 100.0,
2126                "completion_tokens": 50.0
2127            }
2128        }"#;
2129
2130        let chunk: SseChunk = serde_json::from_str(json).unwrap();
2131        let usage = chunk.usage.unwrap();
2132        assert_eq!(usage.prompt_tokens, 100);
2133        assert_eq!(usage.completion_tokens, 50);
2134    }
2135
2136    #[test]
2137    fn test_api_usage_deserializes_integer_compatible_numbers() {
2138        let json = r#"{
2139            "prompt_tokens": 42.0,
2140            "completion_tokens": 7
2141        }"#;
2142
2143        let usage: ApiUsage = serde_json::from_str(json).unwrap();
2144        assert_eq!(usage.prompt_tokens, 42);
2145        assert_eq!(usage.completion_tokens, 7);
2146    }
2147
2148    #[test]
2149    fn test_api_usage_deserializes_cached_tokens() {
2150        let json = r#"{
2151            "prompt_tokens": 42,
2152            "completion_tokens": 7,
2153            "prompt_tokens_details": {
2154                "cached_tokens": 10
2155            }
2156        }"#;
2157
2158        let usage: ApiUsage = serde_json::from_str(json).unwrap();
2159        assert_eq!(usage.prompt_tokens, 42);
2160        assert_eq!(usage.completion_tokens, 7);
2161        assert_eq!(usage.prompt_tokens_details.unwrap().cached_tokens, 10);
2162    }
2163
2164    #[test]
2165    fn test_process_sse_data_maps_cached_tokens_to_cache_read_usage() {
2166        let results = process_sse_data(
2167            r#"{
2168                "choices": [],
2169                "usage": {
2170                    "prompt_tokens": 42,
2171                    "completion_tokens": 7,
2172                    "prompt_tokens_details": {
2173                        "cached_tokens": 10
2174                    }
2175                }
2176            }"#,
2177        );
2178
2179        assert!(matches!(
2180            results.as_slice(),
2181            [SseProcessResult::Usage(Usage {
2182                input_tokens: 42,
2183                output_tokens: 7,
2184                cached_input_tokens: 10,
2185                cache_creation_input_tokens: 0,
2186            })]
2187        ));
2188    }
2189
2190    #[test]
2191    fn test_api_usage_rejects_fractional_numbers() {
2192        let json = r#"{
2193            "prompt_tokens": 42.5,
2194            "completion_tokens": 7
2195        }"#;
2196
2197        let usage: std::result::Result<ApiUsage, _> = serde_json::from_str(json);
2198        assert!(usage.is_err());
2199    }
2200
2201    #[test]
2202    fn test_use_max_tokens_alias_for_vendor_urls() {
2203        assert!(!use_max_tokens_alias(DEFAULT_BASE_URL));
2204        assert!(use_max_tokens_alias(BASE_URL_KIMI));
2205        assert!(use_max_tokens_alias(BASE_URL_ZAI));
2206        assert!(use_max_tokens_alias(BASE_URL_MINIMAX));
2207    }
2208
2209    #[test]
2210    fn test_requires_responses_api_only_for_legacy_codex_model() {
2211        assert!(requires_responses_api(MODEL_GPT52_CODEX));
2212        assert!(!requires_responses_api(MODEL_GPT53_CODEX));
2213        assert!(!requires_responses_api(MODEL_GPT54));
2214    }
2215
2216    #[test]
2217    fn test_should_use_responses_api_for_official_agentic_requests() {
2218        let request = ChatRequest {
2219            system: String::new(),
2220            messages: vec![agent_sdk_foundation::llm::Message::user("Hello")],
2221            tools: Some(vec![agent_sdk_foundation::llm::Tool {
2222                name: "read_file".to_string(),
2223                description: "Read a file".to_string(),
2224                input_schema: serde_json::json!({"type": "object"}),
2225                display_name: "Read File".to_string(),
2226                tier: agent_sdk_foundation::ToolTier::Observe,
2227            }]),
2228            max_tokens: 1024,
2229            max_tokens_explicit: true,
2230            session_id: Some("thread-1".to_string()),
2231            cached_content: None,
2232            thinking: None,
2233            tool_choice: None,
2234            response_format: None,
2235        };
2236
2237        assert!(should_use_responses_api(
2238            DEFAULT_BASE_URL,
2239            MODEL_GPT54,
2240            &request
2241        ));
2242        assert!(!should_use_responses_api(
2243            BASE_URL_KIMI,
2244            MODEL_GPT54,
2245            &request
2246        ));
2247    }
2248
2249    #[test]
2250    fn test_build_api_reasoning_maps_enabled_budget_to_effort() {
2251        let reasoning = build_api_reasoning(Some(&ThinkingConfig::new(40_000))).unwrap();
2252        assert!(matches!(reasoning.effort, ReasoningEffort::XHigh));
2253    }
2254
2255    #[test]
2256    fn test_build_api_reasoning_uses_explicit_effort() {
2257        let reasoning =
2258            build_api_reasoning(Some(&ThinkingConfig::adaptive_with_effort(Effort::High))).unwrap();
2259        assert!(matches!(reasoning.effort, ReasoningEffort::High));
2260    }
2261
2262    #[test]
2263    fn test_build_api_reasoning_omits_adaptive_without_effort() {
2264        assert!(build_api_reasoning(Some(&ThinkingConfig::adaptive())).is_none());
2265    }
2266
2267    #[test]
2268    fn test_openai_rejects_adaptive_thinking() {
2269        let provider = OpenAIProvider::gpt54("test-key".to_string());
2270        let error = provider
2271            .validate_thinking_config(Some(&ThinkingConfig::adaptive()))
2272            .unwrap_err();
2273        assert!(
2274            error
2275                .to_string()
2276                .contains("adaptive thinking is not supported")
2277        );
2278    }
2279
2280    #[test]
2281    fn test_openai_non_reasoning_models_reject_thinking() {
2282        let provider = OpenAIProvider::gpt4o("test-key".to_string());
2283        let error = provider
2284            .validate_thinking_config(Some(&ThinkingConfig::new(10_000)))
2285            .unwrap_err();
2286        assert!(error.to_string().contains("thinking is not supported"));
2287    }
2288
2289    #[test]
2290    fn test_request_serialization_openai_uses_max_completion_tokens_only() {
2291        let messages = vec![ApiMessage {
2292            role: ApiRole::User,
2293            content: Some("Hello".to_string()),
2294            tool_calls: None,
2295            tool_call_id: None,
2296        }];
2297
2298        let request = ApiChatRequest {
2299            model: "gpt-4o",
2300            messages: &messages,
2301            max_completion_tokens: Some(1024),
2302            max_tokens: None,
2303            tools: None,
2304            tool_choice: None,
2305            reasoning: None,
2306            response_format: None,
2307        };
2308
2309        let json = serde_json::to_string(&request).unwrap();
2310        assert!(json.contains("\"max_completion_tokens\":1024"));
2311        assert!(!json.contains("\"max_tokens\""));
2312    }
2313
2314    #[test]
2315    fn test_request_serialization_with_max_tokens_alias() {
2316        let messages = vec![ApiMessage {
2317            role: ApiRole::User,
2318            content: Some("Hello".to_string()),
2319            tool_calls: None,
2320            tool_call_id: None,
2321        }];
2322
2323        let request = ApiChatRequest {
2324            model: "glm-5",
2325            messages: &messages,
2326            max_completion_tokens: Some(1024),
2327            max_tokens: Some(1024),
2328            tools: None,
2329            tool_choice: None,
2330            reasoning: None,
2331            response_format: None,
2332        };
2333
2334        let json = serde_json::to_string(&request).unwrap();
2335        assert!(json.contains("\"max_completion_tokens\":1024"));
2336        assert!(json.contains("\"max_tokens\":1024"));
2337    }
2338
2339    #[test]
2340    fn test_streaming_request_serialization_openai_default() {
2341        let messages = vec![ApiMessage {
2342            role: ApiRole::User,
2343            content: Some("Hello".to_string()),
2344            tool_calls: None,
2345            tool_call_id: None,
2346        }];
2347
2348        let request = ApiChatRequestStreaming {
2349            model: "gpt-4o",
2350            messages: &messages,
2351            max_completion_tokens: Some(1024),
2352            max_tokens: None,
2353            tools: None,
2354            tool_choice: None,
2355            reasoning: None,
2356            response_format: None,
2357            stream_options: Some(ApiStreamOptions {
2358                include_usage: true,
2359            }),
2360            stream: true,
2361        };
2362
2363        let json = serde_json::to_string(&request).unwrap();
2364        assert!(json.contains("\"stream\":true"));
2365        assert!(json.contains("\"model\":\"gpt-4o\""));
2366        assert!(json.contains("\"max_completion_tokens\":1024"));
2367        assert!(json.contains("\"stream_options\":{\"include_usage\":true}"));
2368        assert!(!json.contains("\"max_tokens\""));
2369    }
2370
2371    #[test]
2372    fn test_streaming_request_serialization_with_max_tokens_alias() {
2373        let messages = vec![ApiMessage {
2374            role: ApiRole::User,
2375            content: Some("Hello".to_string()),
2376            tool_calls: None,
2377            tool_call_id: None,
2378        }];
2379
2380        let request = ApiChatRequestStreaming {
2381            model: "kimi-k2-thinking",
2382            messages: &messages,
2383            max_completion_tokens: Some(1024),
2384            max_tokens: Some(1024),
2385            tools: None,
2386            tool_choice: None,
2387            reasoning: None,
2388            response_format: None,
2389            stream_options: None,
2390            stream: true,
2391        };
2392
2393        let json = serde_json::to_string(&request).unwrap();
2394        assert!(json.contains("\"max_completion_tokens\":1024"));
2395        assert!(json.contains("\"max_tokens\":1024"));
2396        assert!(!json.contains("\"stream_options\""));
2397    }
2398
2399    #[test]
2400    fn test_request_serialization_includes_reasoning_when_present() {
2401        let messages = vec![ApiMessage {
2402            role: ApiRole::User,
2403            content: Some("Hello".to_string()),
2404            tool_calls: None,
2405            tool_call_id: None,
2406        }];
2407
2408        let request = ApiChatRequest {
2409            model: MODEL_GPT54,
2410            messages: &messages,
2411            max_completion_tokens: Some(1024),
2412            max_tokens: None,
2413            tools: None,
2414            tool_choice: None,
2415            reasoning: Some(ApiReasoning {
2416                effort: ReasoningEffort::High,
2417            }),
2418            response_format: None,
2419        };
2420
2421        let json = serde_json::to_string(&request).unwrap();
2422        assert!(json.contains("\"reasoning\":{\"effort\":\"high\"}"));
2423    }
2424
2425    #[test]
2426    fn test_response_format_serializes_as_json_schema() {
2427        let messages = vec![ApiMessage {
2428            role: ApiRole::User,
2429            content: Some("Hello".to_string()),
2430            tool_calls: None,
2431            tool_call_id: None,
2432        }];
2433
2434        let response_format = Some(ApiResponseFormat::from_response_format(
2435            &agent_sdk_foundation::llm::ResponseFormat::new(
2436                "person",
2437                serde_json::json!({"type": "object"}),
2438            ),
2439        ));
2440
2441        let request = ApiChatRequest {
2442            model: "gpt-4o",
2443            messages: &messages,
2444            max_completion_tokens: Some(1024),
2445            max_tokens: None,
2446            tools: None,
2447            tool_choice: None,
2448            reasoning: None,
2449            response_format,
2450        };
2451
2452        let json = serde_json::to_value(&request).unwrap();
2453        assert_eq!(json["response_format"]["type"], "json_schema");
2454        assert_eq!(json["response_format"]["json_schema"]["name"], "person");
2455        assert_eq!(json["response_format"]["json_schema"]["strict"], true);
2456        assert_eq!(
2457            json["response_format"]["json_schema"]["schema"]["type"],
2458            "object"
2459        );
2460    }
2461
2462    #[test]
2463    fn test_response_format_omitted_when_absent() {
2464        let messages = vec![ApiMessage {
2465            role: ApiRole::User,
2466            content: Some("Hello".to_string()),
2467            tool_calls: None,
2468            tool_call_id: None,
2469        }];
2470
2471        let request = ApiChatRequest {
2472            model: "gpt-4o",
2473            messages: &messages,
2474            max_completion_tokens: Some(1024),
2475            max_tokens: None,
2476            tools: None,
2477            tool_choice: None,
2478            reasoning: None,
2479            response_format: None,
2480        };
2481
2482        let json = serde_json::to_string(&request).unwrap();
2483        assert!(!json.contains("response_format"));
2484    }
2485}