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