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