diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs index fa33f3524b..8c18f7515a 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs @@ -55,6 +55,7 @@ use tungstenite::protocol::WebSocketConfig; use url::Url; const REALTIME_WIRE_LOG_TARGET: &str = "codex_api::realtime_websocket::wire"; +const OPENAI_REALTIME_API_BASE_URL: &str = "https://api.openai.com/v1"; struct WsStream { tx_command: mpsc::Sender, @@ -696,11 +697,21 @@ fn contains_transcript_entry(entries: &[RealtimeTranscriptEntry], role: &str, te pub struct RealtimeWebsocketClient { provider: Provider, + webrtc_sideband_base_url: String, } impl RealtimeWebsocketClient { pub fn new(provider: Provider) -> Self { - Self { provider } + Self { + provider, + webrtc_sideband_base_url: OPENAI_REALTIME_API_BASE_URL.to_string(), + } + } + + /// Overrides the direct WebRTC sideband URL for local development and tests. + pub fn with_webrtc_sideband_base_url(mut self, base_url: String) -> Self { + self.webrtc_sideband_base_url = base_url; + self } pub async fn connect( @@ -775,13 +786,7 @@ impl RealtimeWebsocketClient { ) -> Result { // Keep the parser/session query shaping from standalone realtime while replacing the model // query with a call_id join onto an existing WebRTC session. - let ws_url = websocket_url_from_api_url_for_call( - self.provider.base_url.as_str(), - self.provider.query_params.as_ref(), - config.event_parser, - config.session_mode, - call_id, - )?; + let ws_url = self.webrtc_sideband_url(config.event_parser, config.session_mode, call_id)?; self.connect_realtime_websocket_url( ws_url, config, @@ -792,6 +797,21 @@ impl RealtimeWebsocketClient { .await } + fn webrtc_sideband_url( + &self, + event_parser: RealtimeEventParser, + session_mode: RealtimeSessionMode, + call_id: &str, + ) -> Result { + websocket_url_from_api_url_for_call( + self.webrtc_sideband_base_url.as_str(), + /*query_params*/ None, + event_parser, + session_mode, + call_id, + ) + } + async fn connect_realtime_websocket_url( &self, ws_url: Url, @@ -1013,6 +1033,7 @@ fn normalize_realtime_path(url: &mut Url, event_parser: RealtimeEventParser) { mod tests { use super::*; use crate::endpoint::realtime_websocket::protocol::RealtimeTranscriptEntry; + use crate::provider::RetryConfig; use codex_protocol::protocol::RealtimeHandoffRequested; use codex_protocol::protocol::RealtimeInputAudioSpeechStarted; use codex_protocol::protocol::RealtimeNoopRequested; @@ -1780,6 +1801,34 @@ mod tests { ); } + #[test] + fn webrtc_frameless_sideband_ignores_provider_base_url() { + let client = RealtimeWebsocketClient::new(Provider { + name: "chatgpt".to_string(), + base_url: "https://chatgpt.com/backend-api/codex".to_string(), + query_params: None, + headers: HeaderMap::new(), + retry: RetryConfig { + max_attempts: 0, + base_delay: Duration::ZERO, + retry_429: false, + retry_5xx: false, + retry_transport: false, + }, + stream_idle_timeout: Duration::from_secs(5), + }); + + let url = client + .webrtc_sideband_url( + RealtimeEventParser::FramelessBidi, + RealtimeSessionMode::Conversational, + "rtc_test", + ) + .expect("build ws url"); + + assert_eq!(url.as_str(), "wss://api.openai.com/v1/live/rtc_test"); + } + #[tokio::test] async fn e2e_connect_and_exchange_events_against_mock_ws_server() { let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); diff --git a/codex-rs/codex-api/tests/realtime_websocket_e2e.rs b/codex-rs/codex-api/tests/realtime_websocket_e2e.rs index 5d0f8f910c..c3018fa929 100644 --- a/codex-rs/codex-api/tests/realtime_websocket_e2e.rs +++ b/codex-rs/codex-api/tests/realtime_websocket_e2e.rs @@ -244,7 +244,8 @@ async fn realtime_ws_connect_webrtc_sideband_retries_join_until_server_is_availa provider.retry.max_attempts = 1; provider.retry.base_delay = Duration::from_millis(100); - let client = RealtimeWebsocketClient::new(provider); + let client = RealtimeWebsocketClient::new(provider) + .with_webrtc_sideband_base_url(format!("http://{addr}")); let connection = client .connect_webrtc_sideband( RealtimeSessionConfig { diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 198c9f858c..740cd6f98c 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -458,6 +458,7 @@ struct ConversationState { struct RealtimeStart { api_provider: ApiProvider, + realtime_sideband_base_url: Option, extra_headers: Option, client_managed_handoffs: bool, flush_transcript_tail_on_session_end: bool, @@ -518,6 +519,7 @@ impl RealtimeConversationManager { async fn start_inner(&self, start: RealtimeStart) -> CodexResult { let RealtimeStart { api_provider, + realtime_sideband_base_url, extra_headers, client_managed_handoffs, flush_transcript_tail_on_session_end, @@ -569,6 +571,10 @@ impl RealtimeConversationManager { }; let client = RealtimeWebsocketClient::new(api_provider); + let client = match realtime_sideband_base_url { + Some(base_url) => client.with_webrtc_sideband_base_url(base_url), + None => client, + }; let (task, sdp) = if let Some(sdp) = sdp { let call = model_client .create_realtime_call_with_headers( @@ -1090,6 +1096,7 @@ pub(crate) async fn handle_start( struct PreparedRealtimeConversationStart { api_provider: ApiProvider, + realtime_sideband_base_url: Option, extra_headers: Option, client_managed_handoffs: bool, flush_transcript_tail_on_session_end: bool, @@ -1127,7 +1134,8 @@ async fn prepare_realtime_start( .clone() .unwrap_or(ConversationStartTransport::Websocket); let mut api_provider = provider.to_api_provider(Some(AuthMode::ApiKey))?; - if let Some(realtime_ws_base_url) = &config.experimental_realtime_ws_base_url { + let realtime_sideband_base_url = config.experimental_realtime_ws_base_url.clone(); + if let Some(realtime_ws_base_url) = &realtime_sideband_base_url { api_provider.base_url = realtime_ws_base_url.clone(); } let realtime_call_api_provider = @@ -1182,6 +1190,7 @@ async fn prepare_realtime_start( )); Ok(PreparedRealtimeConversationStart { api_provider, + realtime_sideband_base_url, extra_headers: Some(extra_headers), client_managed_handoffs: params.client_managed_handoffs, flush_transcript_tail_on_session_end: params.flush_transcript_tail_on_session_end, @@ -1381,6 +1390,7 @@ async fn handle_start_inner( ) -> CodexResult<()> { let PreparedRealtimeConversationStart { api_provider, + realtime_sideband_base_url, extra_headers, client_managed_handoffs, flush_transcript_tail_on_session_end, @@ -1401,6 +1411,7 @@ async fn handle_start_inner( }; let start = RealtimeStart { api_provider, + realtime_sideband_base_url, extra_headers, client_managed_handoffs, flush_transcript_tail_on_session_end,