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