diff --git a/code-rs/core/src/client.rs b/code-rs/core/src/client.rs index 143fc4b48a7..948dd391888 100644 --- a/code-rs/core/src/client.rs +++ b/code-rs/core/src/client.rs @@ -60,6 +60,9 @@ use code_otel::otel_event_manager::OtelEventManager; use std::sync::Arc; use std::sync::Mutex; +const RESPONSES_BETA_HEADER_V1: &str = "responses=v1"; +const RESPONSES_BETA_HEADER_EXPERIMENTAL: &str = "responses=experimental"; + #[derive(Debug, Deserialize)] struct ErrorResponse { error: Error, @@ -494,12 +497,25 @@ impl ModelClient { .create_request_builder(&self.client, &auth) .await?; + let has_beta_header = req_builder + .try_clone() + .and_then(|builder| builder.build().ok()) + .map_or(false, |req| req.headers().contains_key("OpenAI-Beta")); + + if !has_beta_header { + let beta_value = if self.provider.is_public_openai_responses_endpoint() { + RESPONSES_BETA_HEADER_V1 + } else { + RESPONSES_BETA_HEADER_EXPERIMENTAL + }; + req_builder = req_builder.header("OpenAI-Beta", beta_value); + } + // `Codex-Task-Type` differentiates traffic for caching; default to "standard" until // task-specific dispatch is re-introduced. let codex_task_type = "standard"; req_builder = req_builder - .header("OpenAI-Beta", "responses=experimental") // Send `conversation_id`/`session_id` so the server can hit the prompt-cache. .header("conversation_id", session_id_str.clone()) .header("session_id", session_id_str.clone()) @@ -1360,6 +1376,8 @@ async fn stream_from_fixture( #[cfg(test)] mod tests { use super::*; + use crate::model_provider_info::{ModelProviderInfo, WireApi}; + use std::collections::HashMap; use serde_json::json; use tokio::sync::mpsc; use tokio_test::io::Builder as IoBuilder; @@ -1369,6 +1387,141 @@ mod tests { // Helpers // ──────────────────────────── + #[tokio::test] + async fn responses_request_uses_beta_header_for_public_openai() { + let provider = ModelProviderInfo { + name: "openai".to_string(), + base_url: Some("https://api.openai.com/v1".to_string()), + env_key: None, + env_key_instructions: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: Some(0), + stream_max_retries: None, + stream_idle_timeout_ms: None, + requires_openai_auth: false, + openrouter: None, + }; + + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("client"); + + let mut builder = provider + .create_request_builder(&client, &None) + .await + .expect("builder"); + let has_beta = builder + .try_clone() + .and_then(|b| b.build().ok()) + .map_or(false, |req| req.headers().contains_key("OpenAI-Beta")); + if !has_beta { + builder = builder.header("OpenAI-Beta", RESPONSES_BETA_HEADER_V1); + } + let request = builder + .try_clone() + .expect("clone request builder") + .build() + .expect("build request"); + + let header_value = request + .headers() + .get("OpenAI-Beta") + .expect("OpenAI-Beta header present"); + assert_eq!(header_value, RESPONSES_BETA_HEADER_V1); + } + + #[tokio::test] + async fn responses_request_uses_experimental_for_backend() { + let provider = ModelProviderInfo { + name: "backend".to_string(), + base_url: Some("https://chatgpt.com/backend-api/codex".to_string()), + env_key: None, + env_key_instructions: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: Some(0), + stream_max_retries: None, + stream_idle_timeout_ms: None, + requires_openai_auth: false, + openrouter: None, + }; + + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("client"); + + let mut builder = provider + .create_request_builder(&client, &None) + .await + .expect("builder"); + let has_beta = builder + .try_clone() + .and_then(|b| b.build().ok()) + .map_or(false, |req| req.headers().contains_key("OpenAI-Beta")); + if !has_beta { + builder = builder.header("OpenAI-Beta", RESPONSES_BETA_HEADER_EXPERIMENTAL); + } + let request = builder + .try_clone() + .expect("clone request builder") + .build() + .expect("build request"); + + let header_value = request + .headers() + .get("OpenAI-Beta") + .expect("OpenAI-Beta header present"); + assert_eq!(header_value, RESPONSES_BETA_HEADER_EXPERIMENTAL); + } + + #[tokio::test] + async fn responses_request_respects_preexisting_beta_header() { + let mut headers = HashMap::new(); + headers.insert("OpenAI-Beta".to_string(), "custom".to_string()); + let provider = ModelProviderInfo { + name: "custom".to_string(), + base_url: Some("https://api.openai.com/v1".to_string()), + env_key: None, + env_key_instructions: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: Some(headers), + env_http_headers: None, + request_max_retries: Some(0), + stream_max_retries: None, + stream_idle_timeout_ms: None, + requires_openai_auth: false, + openrouter: None, + }; + + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("client"); + + let request = provider + .create_request_builder(&client, &None) + .await + .expect("builder") + .try_clone() + .expect("clone request builder") + .build() + .expect("build request"); + + let header_value = request + .headers() + .get("OpenAI-Beta") + .expect("OpenAI-Beta header present"); + assert_eq!(header_value, "custom"); + } + /// Runs the SSE parser on pre-chunked byte slices and returns every event /// (including any final `Err` from a stream-closure check). async fn collect_events( diff --git a/code-rs/core/src/model_provider_info.rs b/code-rs/core/src/model_provider_info.rs index 2f1b4436d51..463ac6a453d 100644 --- a/code-rs/core/src/model_provider_info.rs +++ b/code-rs/core/src/model_provider_info.rs @@ -252,7 +252,7 @@ impl ModelProviderInfo { }) } - pub(crate) fn get_full_url(&self, auth: &Option) -> String { +pub(crate) fn get_full_url(&self, auth: &Option) -> String { let default_base_url = if matches!( auth, Some(CodexAuth { @@ -291,6 +291,35 @@ impl ModelProviderInfo { .unwrap_or(false) } + pub(crate) fn is_backend_responses_endpoint(&self) -> bool { + if self.wire_api != WireApi::Responses { + return false; + } + + if self.name.eq_ignore_ascii_case("backend") { + return true; + } + + self.base_url + .as_ref() + .map_or(false, |base| base.contains("/backend-api")) + } + + pub(crate) fn is_public_openai_responses_endpoint(&self) -> bool { + if self.wire_api != WireApi::Responses { + return false; + } + if self.is_backend_responses_endpoint() || self.is_azure_responses_endpoint() { + return false; + } + + self.base_url + .as_ref() + .and_then(|base| url::Url::parse(base).ok()) + .and_then(|parsed| parsed.host_str().map(|host| host.eq_ignore_ascii_case("api.openai.com"))) + .unwrap_or(true) + } + /// Apply provider-specific HTTP headers (both static and environment-based) /// onto an existing `reqwest::RequestBuilder` and return the updated /// builder. diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 3c21552dcb2..f93d1c72985 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -55,6 +55,9 @@ use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::ResponseItem; use std::sync::Arc; +const RESPONSES_BETA_HEADER_V1: &str = "responses=v1"; +const RESPONSES_BETA_HEADER_EXPERIMENTAL: &str = "responses=experimental"; + #[derive(Debug, Deserialize)] struct ErrorResponse { error: Error, @@ -288,8 +291,21 @@ impl ModelClient { .await .map_err(StreamAttemptError::Fatal)?; + let has_beta_header = req_builder + .try_clone() + .and_then(|builder| builder.build().ok()) + .map_or(false, |req| req.headers().contains_key("OpenAI-Beta")); + + if !has_beta_header { + let beta_value = if self.provider.is_public_openai_responses_endpoint() { + RESPONSES_BETA_HEADER_V1 + } else { + RESPONSES_BETA_HEADER_EXPERIMENTAL + }; + req_builder = req_builder.header("OpenAI-Beta", beta_value); + } + req_builder = req_builder - .header("OpenAI-Beta", "responses=experimental") // Send session_id for compatibility. .header("conversation_id", self.conversation_id.to_string()) .header("session_id", self.conversation_id.to_string()) @@ -933,6 +949,7 @@ fn is_context_window_error(error: &Error) -> bool { mod tests { use super::*; use assert_matches::assert_matches; + use std::collections::HashMap; use serde_json::json; use tokio::sync::mpsc; use tokio_test::io::Builder as IoBuilder; @@ -971,6 +988,145 @@ mod tests { events } + #[tokio::test] + async fn responses_request_sets_beta_header_for_public_openai() { + let provider = ModelProviderInfo { + name: "openai".to_string(), + base_url: Some("https://api.openai.com/v1".to_string()), + env_key: None, + env_key_instructions: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: Some(0), + stream_max_retries: None, + stream_idle_timeout_ms: None, + requires_openai_auth: false, + openrouter: None, + }; + + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("client"); + + let mut builder = provider + .create_request_builder(&client, &None) + .await + .expect("builder"); + + let has_beta = builder + .try_clone() + .and_then(|b| b.build().ok()) + .map_or(false, |req| req.headers().contains_key("OpenAI-Beta")); + if !has_beta { + builder = builder.header("OpenAI-Beta", RESPONSES_BETA_HEADER_V1); + } + + let request = builder + .try_clone() + .expect("clone request builder") + .build() + .expect("build request"); + + let header_value = request + .headers() + .get("OpenAI-Beta") + .expect("OpenAI-Beta header present"); + assert_eq!(header_value, RESPONSES_BETA_HEADER_V1); + } + + #[tokio::test] + async fn responses_request_sets_beta_header_for_backend() { + let provider = ModelProviderInfo { + name: "backend".to_string(), + base_url: Some("https://chatgpt.com/backend-api/codex".to_string()), + env_key: None, + env_key_instructions: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: Some(0), + stream_max_retries: None, + stream_idle_timeout_ms: None, + requires_openai_auth: false, + openrouter: None, + }; + + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("client"); + + let mut builder = provider + .create_request_builder(&client, &None) + .await + .expect("builder"); + + let has_beta = builder + .try_clone() + .and_then(|b| b.build().ok()) + .map_or(false, |req| req.headers().contains_key("OpenAI-Beta")); + if !has_beta { + builder = builder.header("OpenAI-Beta", RESPONSES_BETA_HEADER_EXPERIMENTAL); + } + + let request = builder + .try_clone() + .expect("clone request builder") + .build() + .expect("build request"); + + let header_value = request + .headers() + .get("OpenAI-Beta") + .expect("OpenAI-Beta header present"); + assert_eq!(header_value, RESPONSES_BETA_HEADER_EXPERIMENTAL); + } + + #[tokio::test] + async fn responses_request_respects_existing_beta_header() { + let mut headers = HashMap::new(); + headers.insert("OpenAI-Beta".to_string(), "custom".to_string()); + let provider = ModelProviderInfo { + name: "custom".to_string(), + base_url: Some("https://api.openai.com/v1".to_string()), + env_key: None, + env_key_instructions: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: Some(headers), + env_http_headers: None, + request_max_retries: Some(0), + stream_max_retries: None, + stream_idle_timeout_ms: None, + requires_openai_auth: false, + openrouter: None, + }; + + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("client"); + + let request = provider + .create_request_builder(&client, &None) + .await + .expect("builder") + .try_clone() + .expect("clone request builder") + .build() + .expect("build request"); + + let header_value = request + .headers() + .get("OpenAI-Beta") + .expect("OpenAI-Beta header present"); + assert_eq!(header_value, "custom"); + } + /// Builds an in-memory SSE stream from JSON fixtures and returns only the /// successfully parsed events (panics on internal channel errors). async fn run_sse( diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index a5707b34d15..015a360859f 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -177,6 +177,35 @@ impl ModelProviderInfo { .unwrap_or(false) } + pub(crate) fn is_backend_responses_endpoint(&self) -> bool { + if self.wire_api != WireApi::Responses { + return false; + } + + if self.name.eq_ignore_ascii_case("backend") { + return true; + } + + self.base_url + .as_ref() + .map_or(false, |base| base.contains("/backend-api")) + } + + pub(crate) fn is_public_openai_responses_endpoint(&self) -> bool { + if self.wire_api != WireApi::Responses { + return false; + } + if self.is_backend_responses_endpoint() || self.is_azure_responses_endpoint() { + return false; + } + + self.base_url + .as_ref() + .and_then(|base| url::Url::parse(base).ok()) + .and_then(|parsed| parsed.host_str().map(|host| host.eq_ignore_ascii_case("api.openai.com"))) + .unwrap_or(true) + } + /// Apply provider-specific HTTP headers (both static and environment-based) /// onto an existing `reqwest::RequestBuilder` and return the updated /// builder. diff --git a/scripts/openai-proxy.js b/scripts/openai-proxy.js index cb4235c58c2..b8b0031721b 100755 --- a/scripts/openai-proxy.js +++ b/scripts/openai-proxy.js @@ -4,7 +4,7 @@ // Features: // - Injects real OPENAI_API_KEY server-side; clients can use a dummy key // - Allows only /v1/chat/completions and /v1/responses -// - Adds OpenAI-Beta: responses=experimental for Responses API +// - Adds OpenAI-Beta: responses=v1 for Responses API // - Sets Accept: text/event-stream by default to stabilize streaming // - Scrubs sensitive headers from logs; logs are structured JSON // - Reuses connections and sets generous timeouts for SSE @@ -52,7 +52,7 @@ const ALLOWED = ['/v1/chat/completions', '/v1/responses']; // LOG_ERROR_BODY=1 -> Log non-2xx response body (truncated) // LOG_ERROR_BODY_BYTES=1024 -> Max bytes to log from error body // STRICT_HEADERS=1 -> Rebuild upstream headers from a minimal allowlist -// RESPONSES_BETA="responses=experimental"|"responses=v1" (override beta header) +// RESPONSES_BETA="responses=v1" (override beta header) const EXIT_ON_5XX = process.env.EXIT_ON_5XX === '1' || false; const READY_FILE = process.env.READY_FILE || ''; const LOG_DEST = (process.env.LOG_DEST || 'stdout').toLowerCase();