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