Skip to main content

vtcode_core/llm/providers/openai/
provider.rs

1#![allow(
2    clippy::collapsible_if,
3    clippy::manual_contains,
4    clippy::nonminimal_bool,
5    clippy::single_match,
6    unused_imports
7)]
8
9use crate::config::TimeoutsConfig;
10use crate::config::constants::{env_vars, models, urls};
11use crate::config::core::{
12    AnthropicConfig, ModelConfig, OpenAIConfig, OpenAIHostedShellConfig, OpenAIPromptCacheSettings,
13    OpenAIServiceTier, PromptCachingConfig,
14};
15use crate::llm::error_display;
16use crate::llm::provider;
17use crate::llm::provider::LLMProvider;
18use crate::models_manager::model_family::find_family_for_model;
19use crate::utils::file_input::{MAX_INPUT_FILE_BYTES, decoded_base64_size};
20use hashbrown::{HashMap, HashSet};
21use reqwest::Client as HttpClient;
22use reqwest::StatusCode;
23use reqwest::header::HeaderMap;
24use serde_json::{Value, json};
25use std::env;
26use std::sync::Arc;
27use std::sync::Mutex;
28use std::time::Duration;
29#[cfg(debug_assertions)]
30use std::time::Instant;
31use tokio::sync::Mutex as AsyncMutex;
32use tracing::debug;
33use uuid::Uuid;
34use vtcode_config::auth::{OpenAIChatGptAuthHandle, OpenAIChatGptSession};
35
36// Import from extracted modules
37use super::CustomProviderAuthHandle;
38use super::harmony;
39use super::request_builder;
40use super::response_parser;
41use super::responses_api::parse_responses_payload;
42use super::types::{MAX_COMPLETION_TOKENS_FIELD, OpenAIResponsesPayload, ResponsesApiState};
43
44mod generation;
45mod streaming;
46mod websocket;
47
48use self::websocket::{OpenAIResponsesWebSocketContinuationCache, OpenAIResponsesWebSocketSession};
49use super::super::{
50    common::{
51        extract_prompt_cache_settings, override_base_url, parse_client_prompt_common, resolve_model,
52    },
53    extract_reasoning_trace,
54};
55use crate::prompts::system::default_system_prompt;
56
57const CHATGPT_CODEX_BASE: &str = "https://chatgpt.com/backend-api/codex";
58const CHATGPT_ACCOUNT_HEADER: &str = "ChatGPT-Account-Id";
59const CHATGPT_ORIGINATOR_HEADER: &str = "originator";
60const CHATGPT_ORIGINATOR_VALUE: &str = "codex_cli_rs";
61const CHATGPT_SESSION_HEADER: &str = "session_id";
62const CHATGPT_USER_AGENT: &str = "VT Code/1.0";
63const INLINE_FILE_LIMIT_ERROR_PREFIX: &str =
64    "Inline OpenAI input_file payload exceeds the 50 MB request limit";
65
66#[derive(Clone, Debug)]
67struct OpenAIRequestAuth {
68    bearer_token: String,
69    chatgpt_account_id: Option<String>,
70}
71
72pub struct OpenAIProvider {
73    api_key: Arc<str>,
74    /// Override provider key for custom providers (e.g., "mycorp").
75    /// When `None`, defaults to `"openai"`.
76    provider_key_override: Option<Arc<str>>,
77    /// Override display name for custom providers.
78    /// When `None`, defaults to `"OpenAI"`.
79    provider_display_override: Option<Arc<str>>,
80    custom_provider_auth: Option<CustomProviderAuthHandle>,
81    openai_chatgpt_auth: Option<OpenAIChatGptAuthHandle>,
82    http_client: HttpClient,
83    base_url: Arc<str>,
84    model: Arc<str>,
85    supported_models_override: Option<Vec<String>>,
86    responses_api_modes: Mutex<HashMap<String, ResponsesApiState>>,
87    prompt_cache_enabled: bool,
88    prompt_cache_settings: OpenAIPromptCacheSettings,
89    model_behavior: Option<ModelConfig>,
90    websocket_mode: bool,
91    responses_store: Option<bool>,
92    responses_include: Vec<String>,
93    service_tier: Option<OpenAIServiceTier>,
94    hosted_shell: OpenAIHostedShellConfig,
95    websocket_session: AsyncMutex<Option<OpenAIResponsesWebSocketSession>>,
96    websocket_continuation_cache: Mutex<Option<OpenAIResponsesWebSocketContinuationCache>>,
97}
98
99impl OpenAIProvider {
100    fn requires_streaming_responses(model: &str) -> bool {
101        matches!(
102            model,
103            models::openai::GPT | models::openai::GPT_5_4 | models::openai::GPT_5_4_PRO
104        )
105    }
106
107    fn model_supports_reasoning_summaries(model: &str) -> bool {
108        find_family_for_model(model).supports_reasoning_summaries
109    }
110
111    fn normalize_reasoning_output(
112        model: &str,
113        mut response: provider::LLMResponse,
114    ) -> provider::LLMResponse {
115        if !Self::model_supports_reasoning_summaries(model) {
116            response.reasoning = None;
117            response.reasoning_details = None;
118        }
119
120        response
121    }
122
123    fn is_responses_api_model(model: &str) -> bool {
124        models::openai::RESPONSES_API_MODELS.contains(&model)
125    }
126
127    fn uses_harmony(model: &str) -> bool {
128        harmony::uses_harmony(model)
129    }
130
131    fn requires_responses_api(model: &str) -> bool {
132        model == models::openai::GPT_5
133    }
134
135    fn default_responses_state(model: &str) -> ResponsesApiState {
136        if Self::requires_responses_api(model) {
137            ResponsesApiState::Required
138        } else if Self::is_responses_api_model(model) {
139            ResponsesApiState::Allowed
140        } else {
141            ResponsesApiState::Disabled
142        }
143    }
144
145    pub fn new(api_key: String) -> Self {
146        Self::with_model_internal(
147            api_key,
148            None,
149            models::openai::DEFAULT_MODEL.to_string(),
150            None,
151            None,
152            TimeoutsConfig::default(),
153            None,
154            None,
155        )
156    }
157
158    pub fn with_model(api_key: String, model: String) -> Self {
159        Self::with_model_internal(
160            api_key,
161            None,
162            model,
163            None,
164            None,
165            TimeoutsConfig::default(),
166            None,
167            None,
168        )
169    }
170
171    pub fn new_with_client(
172        api_key: String,
173        openai_chatgpt_auth: Option<OpenAIChatGptAuthHandle>,
174        model: String,
175        http_client: reqwest::Client,
176        base_url: String,
177        _timeouts: TimeoutsConfig,
178    ) -> Self {
179        use hashbrown::HashMap;
180        use std::sync::Arc;
181        use std::sync::Mutex;
182
183        Self {
184            api_key: Arc::from(api_key.as_str()),
185            provider_key_override: None,
186            provider_display_override: None,
187            custom_provider_auth: None,
188            openai_chatgpt_auth,
189            http_client,
190            base_url: Arc::from(base_url.as_str()),
191            model: Arc::from(model.as_str()),
192            supported_models_override: None,
193            prompt_cache_enabled: false,
194            prompt_cache_settings: Default::default(),
195            responses_api_modes: Mutex::new(HashMap::new()),
196            model_behavior: None,
197            websocket_mode: false,
198            responses_store: None,
199            responses_include: Vec::new(),
200            service_tier: None,
201            hosted_shell: OpenAIHostedShellConfig::default(),
202            websocket_session: AsyncMutex::new(None),
203            websocket_continuation_cache: Mutex::new(None),
204        }
205    }
206
207    #[expect(clippy::too_many_arguments)]
208    pub fn from_config(
209        api_key: Option<String>,
210        openai_chatgpt_auth: Option<OpenAIChatGptAuthHandle>,
211        model: Option<String>,
212        base_url: Option<String>,
213        prompt_cache: Option<PromptCachingConfig>,
214        timeouts: Option<TimeoutsConfig>,
215        _anthropic: Option<AnthropicConfig>,
216        openai: Option<OpenAIConfig>,
217        model_behavior: Option<ModelConfig>,
218    ) -> Self {
219        let api_key_value = api_key.unwrap_or_default();
220        let model_value = resolve_model(model, models::openai::DEFAULT_MODEL);
221
222        Self::with_model_internal(
223            api_key_value,
224            openai_chatgpt_auth,
225            model_value,
226            prompt_cache,
227            base_url,
228            timeouts.unwrap_or_default(),
229            openai,
230            model_behavior,
231        )
232    }
233
234    /// Create a custom OpenAI-compatible provider with overridden identity.
235    #[expect(clippy::too_many_arguments)]
236    pub fn from_custom_config(
237        provider_key: String,
238        display_name: String,
239        api_key: Option<String>,
240        model: Option<String>,
241        base_url: Option<String>,
242        prompt_cache: Option<PromptCachingConfig>,
243        timeouts: Option<TimeoutsConfig>,
244        openai: Option<OpenAIConfig>,
245        model_behavior: Option<ModelConfig>,
246        custom_provider_auth: Option<CustomProviderAuthHandle>,
247        supported_models_override: Option<Vec<String>>,
248    ) -> Self {
249        let mut provider = Self::from_config(
250            api_key,
251            None, // no chatgpt auth for custom providers
252            model,
253            base_url,
254            prompt_cache,
255            timeouts,
256            None, // no anthropic config
257            openai,
258            model_behavior,
259        );
260        provider.provider_key_override = Some(Arc::from(provider_key.as_str()));
261        provider.provider_display_override = Some(Arc::from(display_name.as_str()));
262        provider.custom_provider_auth = custom_provider_auth;
263        provider.supported_models_override = supported_models_override;
264        provider
265    }
266
267    fn with_model_internal(
268        api_key: String,
269        openai_chatgpt_auth: Option<OpenAIChatGptAuthHandle>,
270        model: String,
271        prompt_cache: Option<PromptCachingConfig>,
272        base_url: Option<String>,
273        timeouts: TimeoutsConfig,
274        openai: Option<OpenAIConfig>,
275        model_behavior: Option<ModelConfig>,
276    ) -> Self {
277        let (prompt_cache_enabled, prompt_cache_settings) = extract_prompt_cache_settings(
278            prompt_cache,
279            |providers| &providers.openai,
280            |cfg, provider_settings| cfg.enabled && provider_settings.enabled,
281        );
282
283        let using_chatgpt_auth = openai_chatgpt_auth.is_some();
284        let resolved_base_url = override_base_url(
285            if using_chatgpt_auth {
286                CHATGPT_CODEX_BASE
287            } else {
288                urls::OPENAI_API_BASE
289            },
290            base_url,
291            Some(env_vars::OPENAI_BASE_URL),
292        );
293
294        let mut responses_api_modes = HashMap::new();
295        let default_state = Self::default_responses_state(&model);
296        let is_chatgpt_backend = using_chatgpt_auth && resolved_base_url.contains("chatgpt.com");
297        let is_xai = resolved_base_url.contains("api.x.ai");
298        let websocket_mode = openai
299            .as_ref()
300            .map(|cfg| cfg.websocket_mode)
301            .unwrap_or(false);
302        let responses_store = openai.as_ref().and_then(|cfg| cfg.responses_store);
303        let responses_include = openai
304            .as_ref()
305            .map(|cfg| {
306                cfg.responses_include
307                    .iter()
308                    .map(|value| value.trim())
309                    .filter(|value| !value.is_empty())
310                    .map(ToOwned::to_owned)
311                    .collect::<Vec<_>>()
312            })
313            .unwrap_or_default();
314        let service_tier = openai.as_ref().and_then(|cfg| cfg.service_tier);
315        let hosted_shell = openai
316            .as_ref()
317            .map(|cfg| cfg.hosted_shell.clone())
318            .unwrap_or_default();
319
320        let initial_state = if is_xai {
321            ResponsesApiState::Disabled
322        } else if is_chatgpt_backend {
323            match default_state {
324                ResponsesApiState::Disabled => ResponsesApiState::Allowed,
325                state => state,
326            }
327        } else {
328            default_state
329        };
330        responses_api_modes.insert(model.clone(), initial_state);
331
332        use crate::llm::http_client::HttpClientFactory;
333        let http_client = HttpClientFactory::for_llm(&timeouts);
334
335        Self {
336            api_key: Arc::from(api_key.as_str()),
337            provider_key_override: None,
338            provider_display_override: None,
339            custom_provider_auth: None,
340            openai_chatgpt_auth,
341            http_client,
342            base_url: Arc::from(resolved_base_url.as_str()),
343            model: Arc::from(model.as_str()),
344            supported_models_override: None,
345            responses_api_modes: Mutex::new(responses_api_modes),
346            prompt_cache_enabled,
347            prompt_cache_settings,
348            model_behavior,
349            websocket_mode,
350            responses_store,
351            responses_include,
352            service_tier,
353            hosted_shell,
354            websocket_session: AsyncMutex::new(None),
355            websocket_continuation_cache: Mutex::new(None),
356        }
357    }
358
359    fn is_native_openai_api(&self) -> bool {
360        self.provider_key_override.is_none() && self.base_url.contains("api.openai.com")
361    }
362
363    pub(crate) fn supports_manual_openai_compaction_for_model(&self, model: &str) -> bool {
364        self.is_native_openai_api()
365            && !self.uses_chatgpt_auth()
366            && !matches!(self.responses_api_state(model), ResponsesApiState::Disabled)
367    }
368
369    pub(crate) fn manual_openai_compaction_unavailable_message_for_model(
370        &self,
371        model: &str,
372    ) -> String {
373        let requested = if model.trim().is_empty() {
374            self.model.as_ref()
375        } else {
376            model
377        };
378
379        let (backend, reason) = if self.uses_chatgpt_auth() {
380            (
381                "ChatGPT subscription auth via chatgpt.com backend".to_string(),
382                "ChatGPT subscription auth does not expose the standalone `/responses/compact` endpoint"
383                    .to_string(),
384            )
385        } else if self.provider_key_override.is_some() {
386            (
387                format!("custom OpenAI-compatible provider endpoint ({})", self.base_url),
388                "custom OpenAI-compatible providers are intentionally excluded from the manual `/compact` UX"
389                    .to_string(),
390            )
391        } else if !self.base_url.contains("api.openai.com") {
392            (
393                format!("configured OpenAI-compatible endpoint ({})", self.base_url),
394                "manual `/compact` is restricted to the native OpenAI API host".to_string(),
395            )
396        } else {
397            (
398                "native OpenAI API (api.openai.com)".to_string(),
399                "this model is not Responses-compatible on the native OpenAI API".to_string(),
400            )
401        };
402
403        format!(
404            "Manual `/compact` is available only for the native OpenAI provider on api.openai.com with a Responses-compatible OpenAI model. Active provider/backend/model: {} / {} / {}. Reason: {}.",
405            self.name(),
406            backend,
407            requested,
408            reason,
409        )
410    }
411
412    fn websocket_mode_enabled(&self, model: &str) -> bool {
413        self.websocket_mode
414            && !self.is_chatgpt_backend()
415            && !matches!(self.responses_api_state(model), ResponsesApiState::Disabled)
416    }
417
418    fn hosted_shell_for_model(&self, model: &str) -> Option<&OpenAIHostedShellConfig> {
419        (self.is_native_openai_api()
420            && !matches!(self.responses_api_state(model), ResponsesApiState::Disabled)
421            && self.hosted_shell.enabled
422            && self.hosted_shell.is_valid_for_runtime())
423        .then_some(&self.hosted_shell)
424    }
425
426    fn authorize_with_api_key(
427        &self,
428        builder: reqwest::RequestBuilder,
429        auth: &OpenAIRequestAuth,
430    ) -> reqwest::RequestBuilder {
431        let mut builder = if auth.bearer_token.trim().is_empty() {
432            builder
433        } else {
434            builder.bearer_auth(&auth.bearer_token)
435        };
436
437        if self.is_chatgpt_backend() {
438            if let Some(account_id) = auth
439                .chatgpt_account_id
440                .as_deref()
441                .map(str::trim)
442                .filter(|value| !value.is_empty())
443            {
444                builder = builder.header(CHATGPT_ACCOUNT_HEADER, account_id);
445            }
446            builder = builder
447                .header(CHATGPT_ORIGINATOR_HEADER, CHATGPT_ORIGINATOR_VALUE)
448                .header("User-Agent", CHATGPT_USER_AGENT);
449            if let Ok(session_id) = env::var("VT_SESSION_ID")
450                && !session_id.trim().is_empty()
451            {
452                builder = builder.header(CHATGPT_SESSION_HEADER, session_id);
453            }
454        }
455
456        builder
457    }
458
459    fn uses_chatgpt_auth(&self) -> bool {
460        self.openai_chatgpt_auth.is_some()
461    }
462
463    fn uses_refreshable_auth(&self) -> bool {
464        self.openai_chatgpt_auth.is_some() || self.custom_provider_auth.is_some()
465    }
466
467    fn is_chatgpt_backend(&self) -> bool {
468        self.uses_chatgpt_auth() && self.base_url.contains("chatgpt.com")
469    }
470
471    fn allows_chat_completions_fallback(&self) -> bool {
472        !self.is_chatgpt_backend()
473    }
474
475    fn auth_retryable_status(status: StatusCode) -> bool {
476        matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN)
477    }
478
479    fn new_client_request_id() -> String {
480        format!("vtcode-{}", Uuid::new_v4())
481    }
482
483    fn format_network_error(&self, error: impl std::fmt::Display) -> provider::LLMError {
484        let label = self
485            .provider_display_override
486            .as_deref()
487            .unwrap_or("OpenAI");
488        provider::LLMError::Network {
489            message: error_display::format_llm_error(label, &format!("Network error: {error}")),
490            metadata: None,
491        }
492    }
493
494    fn format_auth_error(&self, error: impl std::fmt::Display) -> provider::LLMError {
495        let label = self
496            .provider_display_override
497            .as_deref()
498            .unwrap_or("OpenAI");
499        provider::LLMError::Authentication {
500            message: error_display::format_llm_error(
501                label,
502                &format!("Authentication error: {error}"),
503            ),
504            metadata: None,
505        }
506    }
507
508    async fn current_api_key(&self) -> Result<String, provider::LLMError> {
509        if let Some(handle) = &self.custom_provider_auth {
510            return handle
511                .current_token()
512                .await
513                .map_err(|e| self.format_auth_error(e));
514        }
515
516        let Some(handle) = &self.openai_chatgpt_auth else {
517            return Ok(self.api_key.to_string());
518        };
519
520        handle
521            .refresh_if_needed()
522            .await
523            .map_err(|e| self.format_auth_error(e))?;
524        handle
525            .current_api_key()
526            .map_err(|e| self.format_auth_error(e))
527    }
528
529    fn request_auth_from_session(&self, session: OpenAIChatGptSession) -> OpenAIRequestAuth {
530        let bearer_token = if self.is_chatgpt_backend() || session.openai_api_key.trim().is_empty()
531        {
532            session.access_token
533        } else {
534            session.openai_api_key
535        };
536
537        OpenAIRequestAuth {
538            bearer_token,
539            chatgpt_account_id: session.account_id,
540        }
541    }
542
543    async fn current_request_auth(&self) -> Result<OpenAIRequestAuth, provider::LLMError> {
544        if let Some(handle) = &self.custom_provider_auth {
545            return Ok(OpenAIRequestAuth {
546                bearer_token: handle
547                    .current_token()
548                    .await
549                    .map_err(|e| self.format_auth_error(e))?,
550                chatgpt_account_id: None,
551            });
552        }
553
554        let Some(handle) = &self.openai_chatgpt_auth else {
555            return Ok(OpenAIRequestAuth {
556                bearer_token: self.api_key.to_string(),
557                chatgpt_account_id: None,
558            });
559        };
560
561        handle
562            .refresh_if_needed()
563            .await
564            .map_err(|e| self.format_auth_error(e))?;
565        let session = handle.snapshot().map_err(|e| self.format_auth_error(e))?;
566        Ok(self.request_auth_from_session(session))
567    }
568
569    async fn refresh_request_auth_for_retry(
570        &self,
571    ) -> Result<OpenAIRequestAuth, provider::LLMError> {
572        if let Some(handle) = &self.custom_provider_auth {
573            return Ok(OpenAIRequestAuth {
574                bearer_token: handle
575                    .force_refresh()
576                    .await
577                    .map_err(|e| self.format_auth_error(e))?,
578                chatgpt_account_id: None,
579            });
580        }
581
582        let Some(handle) = &self.openai_chatgpt_auth else {
583            return Ok(OpenAIRequestAuth {
584                bearer_token: self.api_key.to_string(),
585                chatgpt_account_id: None,
586            });
587        };
588
589        handle
590            .force_refresh()
591            .await
592            .map_err(|e| self.format_auth_error(e))?;
593        let session = handle.snapshot().map_err(|e| self.format_auth_error(e))?;
594        Ok(self.request_auth_from_session(session))
595    }
596
597    async fn refresh_api_key_for_retry(&self) -> Result<String, provider::LLMError> {
598        if let Some(handle) = &self.custom_provider_auth {
599            return handle
600                .force_refresh()
601                .await
602                .map_err(|e| self.format_auth_error(e));
603        }
604
605        let Some(handle) = &self.openai_chatgpt_auth else {
606            return Ok(self.api_key.to_string());
607        };
608
609        handle
610            .force_refresh()
611            .await
612            .map_err(|e| self.format_auth_error(e))?;
613        handle
614            .current_api_key()
615            .map_err(|e| self.format_auth_error(e))
616    }
617
618    async fn send_authorized<F>(
619        &self,
620        build_request: F,
621    ) -> Result<reqwest::Response, provider::LLMError>
622    where
623        F: Fn(&OpenAIRequestAuth) -> reqwest::RequestBuilder,
624    {
625        let auth = self.current_request_auth().await?;
626        let response = build_request(&auth)
627            .send()
628            .await
629            .map_err(|e| self.format_network_error(e))?;
630
631        if self.uses_refreshable_auth() && Self::auth_retryable_status(response.status()) {
632            let retry_auth = self.refresh_request_auth_for_retry().await?;
633            return build_request(&retry_auth)
634                .send()
635                .await
636                .map_err(|e| self.format_network_error(e));
637        }
638
639        Ok(response)
640    }
641
642    fn supports_temperature_parameter(model: &str) -> bool {
643        if model == models::openai::GPT_5
644            || model == models::openai::GPT_5_MINI
645            || model == models::openai::GPT_5_NANO
646        {
647            return false;
648        }
649        true
650    }
651
652    fn responses_api_state(&self, model: &str) -> ResponsesApiState {
653        let mut modes = match self.responses_api_modes.lock() {
654            Ok(guard) => guard,
655            Err(poisoned) => {
656                tracing::warn!("OpenAI responses_api_modes mutex poisoned, recovering");
657                poisoned.into_inner()
658            }
659        };
660        *modes
661            .entry(model.to_string())
662            .or_insert_with(|| Self::default_responses_state(model))
663    }
664
665    fn set_responses_api_state(&self, model: &str, state: ResponsesApiState) {
666        let mut modes = match self.responses_api_modes.lock() {
667            Ok(guard) => guard,
668            Err(poisoned) => {
669                tracing::warn!("OpenAI responses_api_modes mutex poisoned, recovering");
670                poisoned.into_inner()
671            }
672        };
673        modes.insert(model.to_string(), state);
674    }
675
676    fn validate_inline_file_inputs(
677        request: &provider::LLMRequest,
678    ) -> Result<(), provider::LLMError> {
679        Self::validate_inline_file_inputs_with_limit(request, MAX_INPUT_FILE_BYTES)
680    }
681
682    fn validate_inline_file_inputs_with_limit(
683        request: &provider::LLMRequest,
684        max_inline_file_bytes: u64,
685    ) -> Result<(), provider::LLMError> {
686        let mut total_inline_file_bytes = 0u64;
687
688        for message in &request.messages {
689            let provider::MessageContent::Parts(parts) = &message.content else {
690                continue;
691            };
692
693            for part in parts {
694                let provider::ContentPart::File {
695                    filename,
696                    file_data,
697                    ..
698                } = part
699                else {
700                    continue;
701                };
702                let Some(file_data) = file_data else {
703                    continue;
704                };
705
706                let inline_file_bytes = decoded_base64_size(file_data).map_err(|error| {
707                    let formatted = error_display::format_llm_error(
708                        "OpenAI",
709                        &format!("Invalid inline input_file payload: {error}"),
710                    );
711                    provider::LLMError::InvalidRequest {
712                        message: formatted,
713                        metadata: None,
714                    }
715                })?;
716
717                if inline_file_bytes > max_inline_file_bytes {
718                    let file_label = filename.as_deref().unwrap_or("attached file");
719                    let formatted = error_display::format_llm_error(
720                        "OpenAI",
721                        &format!(
722                            "{INLINE_FILE_LIMIT_ERROR_PREFIX}: '{file_label}' is {} bytes",
723                            inline_file_bytes
724                        ),
725                    );
726                    return Err(provider::LLMError::InvalidRequest {
727                        message: formatted,
728                        metadata: None,
729                    });
730                }
731
732                total_inline_file_bytes = total_inline_file_bytes
733                    .checked_add(inline_file_bytes)
734                    .ok_or_else(|| provider::LLMError::InvalidRequest {
735                        message: error_display::format_llm_error(
736                            "OpenAI",
737                            INLINE_FILE_LIMIT_ERROR_PREFIX,
738                        ),
739                        metadata: None,
740                    })?;
741            }
742        }
743
744        if total_inline_file_bytes > max_inline_file_bytes {
745            let formatted = error_display::format_llm_error(
746                "OpenAI",
747                &format!(
748                    "{INLINE_FILE_LIMIT_ERROR_PREFIX}: total inline file bytes = {}",
749                    total_inline_file_bytes
750                ),
751            );
752            return Err(provider::LLMError::InvalidRequest {
753                message: formatted,
754                metadata: None,
755            });
756        }
757
758        Ok(())
759    }
760
761    fn convert_to_openai_format(
762        &self,
763        request: &provider::LLMRequest,
764    ) -> Result<Value, provider::LLMError> {
765        let is_native_openai = self.base_url.contains("api.openai.com");
766        let prompt_cache_key = if is_native_openai {
767            request.prompt_cache_key.as_deref()
768        } else {
769            None
770        };
771        let default_service_tier = if is_native_openai {
772            self.service_tier.map(OpenAIServiceTier::as_str)
773        } else {
774            None
775        };
776        let ctx = request_builder::ChatRequestContext {
777            model: &self.model,
778            base_url: &self.base_url,
779            supports_tools: self.supports_tools(&request.model),
780            supports_parallel_tool_config: self.supports_parallel_tool_config(&request.model),
781            supports_temperature: Self::supports_temperature_parameter(&request.model),
782            prompt_cache_key,
783            default_service_tier,
784        };
785
786        request_builder::build_chat_request(request, &ctx)
787    }
788
789    pub(crate) fn convert_to_openai_responses_format(
790        &self,
791        request: &provider::LLMRequest,
792    ) -> Result<Value, provider::LLMError> {
793        Self::validate_inline_file_inputs(request)?;
794
795        let is_native_openai = self.is_native_openai_api();
796        let prompt_cache_key = if is_native_openai {
797            request.prompt_cache_key.as_deref()
798        } else {
799            None
800        };
801        let default_service_tier = if is_native_openai {
802            self.service_tier.map(OpenAIServiceTier::as_str)
803        } else {
804            None
805        };
806        let is_chatgpt_backend = self.is_chatgpt_backend();
807        let supports_responses_continuation = !is_chatgpt_backend
808            && !matches!(
809                self.responses_api_state(&request.model),
810                ResponsesApiState::Disabled
811            );
812        let ctx = request_builder::ResponsesRequestContext {
813            supports_tools: self.supports_tools(&request.model),
814            supports_parallel_tool_config: self.supports_parallel_tool_config(&request.model),
815            supports_temperature: Self::supports_temperature_parameter(&request.model),
816            supports_reasoning_effort: self.supports_reasoning_effort(&request.model),
817            supports_reasoning: self.supports_reasoning(&request.model),
818            is_responses_api_model: Self::is_responses_api_model(&request.model),
819            include_max_output_tokens: is_native_openai,
820            include_previous_response_id: supports_responses_continuation,
821            include_output_types: !self.is_chatgpt_backend(),
822            include_sampling_parameters: !self.is_chatgpt_backend(),
823            force_response_store_false: self.uses_chatgpt_auth()
824                && self.base_url.contains("chatgpt.com"),
825            include_assistant_phase: is_native_openai,
826            prompt_cache_key,
827            include_prompt_cache_retention: !self.is_chatgpt_backend(),
828            prompt_cache_retention: self.prompt_cache_settings.prompt_cache_retention.as_deref(),
829            default_service_tier,
830            default_response_store: self.responses_store,
831            default_responses_include: (!self.responses_include.is_empty())
832                .then_some(self.responses_include.as_slice()),
833            hosted_shell: self.hosted_shell_for_model(&request.model),
834            include_structured_history_in_input: !is_chatgpt_backend,
835            preserve_structured_history_on_replay: is_chatgpt_backend,
836            preserve_assistant_phase_on_replay: false,
837        };
838
839        request_builder::build_responses_request(request, &ctx)
840    }
841
842    fn parse_openai_response(
843        &self,
844        response_json: Value,
845        model: String,
846    ) -> Result<provider::LLMResponse, provider::LLMError> {
847        let include_cached_prompt_tokens =
848            self.prompt_cache_enabled && self.prompt_cache_settings.surface_metrics;
849        let response = response_parser::parse_chat_response(
850            response_json,
851            model.clone(),
852            include_cached_prompt_tokens,
853        )?;
854        Ok(Self::normalize_reasoning_output(&model, response))
855    }
856
857    fn parse_openai_responses_response(
858        &self,
859        response_json: Value,
860        model: String,
861    ) -> Result<provider::LLMResponse, provider::LLMError> {
862        let include_metrics =
863            self.prompt_cache_enabled && self.prompt_cache_settings.surface_metrics;
864        let response = parse_responses_payload(response_json, model.clone(), include_metrics)?;
865        Ok(Self::normalize_reasoning_output(&model, response))
866    }
867}
868
869#[cfg(test)]
870mod tests;
871
872mod harmony_client;
873mod provider_impl;