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