Skip to main content

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