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_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 provider_key_override: Option<Arc<str>>,
64 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 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 #[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, model,
256 base_url,
257 prompt_cache,
258 timeouts,
259 None, 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 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 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;