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