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