Skip to main content

vtcode_core/llm/providers/openai/
provider.rs

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