mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user