Skip to main content

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