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