Route WebRTC sideband joins to the Realtime API (#35830)

## What changed

- Use `https://api.openai.com/v1` for WebRTC sideband websocket joins instead of deriving the URL from the model provider.
- Keep `experimental_realtime_ws_base_url` as an explicit sideband override for local development and tests.
- Exclude provider query parameters when building sideband URLs.

## Testing

- Add coverage that a frameless sideband join uses `wss://api.openai.com/v1/live/rtc_test` even when the provider points at the ChatGPT backend.
- Update the sideband retry end-to-end test to inject its mock server URL through the new override.

GitOrigin-RevId: 5d3d2cd69905ba04143010ced36a22712825b563
This commit is contained in:
Peter Bakkum
2026-07-28 21:16:06 +00:00
committed by copyberry
parent 50a7328f50
commit 438c9e98db
3 changed files with 71 additions and 10 deletions
@@ -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<WsCommand>,
@@ -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<RealtimeWebsocketConnection, ApiError> {
// 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<Url, ApiError> {
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");
@@ -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 {
+12 -1
View File
@@ -458,6 +458,7 @@ struct ConversationState {
struct RealtimeStart {
api_provider: ApiProvider,
realtime_sideband_base_url: Option<String>,
extra_headers: Option<HeaderMap>,
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<RealtimeStartOutput> {
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<String>,
extra_headers: Option<HeaderMap>,
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,