mirror of
https://github.com/openai/codex.git
synced 2026-04-29 00:55:38 +00:00
Attach WebRTC realtime starts to sideband websocket (#17057)
Summary: - parse the realtime call Location header and join that call over the direct realtime WebSocket - keep WebRTC starts alive on the existing realtime conversation path Validation: - just fmt - git diff --check - cargo check -p codex-api - cargo check -p codex-core --tests - local cargo tests not run; relying on PR CI
This commit is contained in:
@@ -14,6 +14,7 @@ use crate::endpoint::realtime_websocket::protocol::RealtimeTranscriptEntry;
|
||||
use crate::endpoint::realtime_websocket::protocol::parse_realtime_event;
|
||||
use crate::error::ApiError;
|
||||
use crate::provider::Provider;
|
||||
use codex_client::backoff;
|
||||
use codex_client::maybe_build_rustls_client_config_with_custom_ca;
|
||||
use codex_utils_rustls_provider::ensure_rustls_crypto_provider;
|
||||
use futures::SinkExt;
|
||||
@@ -28,6 +29,7 @@ use tokio::net::TcpStream;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::time::sleep;
|
||||
use tokio_tungstenite::MaybeTlsStream;
|
||||
use tokio_tungstenite::WebSocketStream;
|
||||
use tokio_tungstenite::tungstenite::Error as WsError;
|
||||
@@ -37,6 +39,7 @@ use tracing::debug;
|
||||
use tracing::error;
|
||||
use tracing::info;
|
||||
use tracing::trace;
|
||||
use tracing::warn;
|
||||
use tungstenite::protocol::WebSocketConfig;
|
||||
use url::Url;
|
||||
|
||||
@@ -455,7 +458,6 @@ impl RealtimeWebsocketClient {
|
||||
extra_headers: HeaderMap,
|
||||
default_headers: HeaderMap,
|
||||
) -> Result<RealtimeWebsocketConnection, ApiError> {
|
||||
ensure_rustls_crypto_provider();
|
||||
let ws_url = websocket_url_from_api_url(
|
||||
self.provider.base_url.as_str(),
|
||||
self.provider.query_params.as_ref(),
|
||||
@@ -463,6 +465,78 @@ impl RealtimeWebsocketClient {
|
||||
config.event_parser,
|
||||
config.session_mode,
|
||||
)?;
|
||||
self.connect_realtime_websocket_url(ws_url, config, extra_headers, default_headers)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn connect_webrtc_sideband(
|
||||
&self,
|
||||
config: RealtimeSessionConfig,
|
||||
call_id: &str,
|
||||
extra_headers: HeaderMap,
|
||||
default_headers: HeaderMap,
|
||||
) -> Result<RealtimeWebsocketConnection, ApiError> {
|
||||
// The WebRTC call already exists; this loop only retries joining its sideband control
|
||||
// socket. Once joined, the returned connection is the same reader/writer state that the
|
||||
// ordinary websocket start path uses.
|
||||
for attempt in 0..=self.provider.retry.max_attempts {
|
||||
let result = self
|
||||
.connect_webrtc_sideband_once(
|
||||
config.clone(),
|
||||
call_id,
|
||||
extra_headers.clone(),
|
||||
default_headers.clone(),
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(connection) => return Ok(connection),
|
||||
Err(err) if attempt < self.provider.retry.max_attempts => {
|
||||
let delay = backoff(self.provider.retry.base_delay, attempt + 1);
|
||||
warn!(
|
||||
attempt = attempt + 1,
|
||||
call_id,
|
||||
delay_ms = delay.as_millis(),
|
||||
"realtime sideband websocket connect failed; retrying: {err}"
|
||||
);
|
||||
sleep(delay).await;
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
Err(ApiError::Stream(
|
||||
"realtime sideband websocket retry loop exhausted".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn connect_webrtc_sideband_once(
|
||||
&self,
|
||||
config: RealtimeSessionConfig,
|
||||
call_id: &str,
|
||||
extra_headers: HeaderMap,
|
||||
default_headers: HeaderMap,
|
||||
) -> 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,
|
||||
)?;
|
||||
self.connect_realtime_websocket_url(ws_url, config, extra_headers, default_headers)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn connect_realtime_websocket_url(
|
||||
&self,
|
||||
ws_url: Url,
|
||||
config: RealtimeSessionConfig,
|
||||
extra_headers: HeaderMap,
|
||||
default_headers: HeaderMap,
|
||||
) -> Result<RealtimeWebsocketConnection, ApiError> {
|
||||
ensure_rustls_crypto_provider();
|
||||
|
||||
let mut request = ws_url
|
||||
.as_str()
|
||||
@@ -596,6 +670,24 @@ fn websocket_url_from_api_url(
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn websocket_url_from_api_url_for_call(
|
||||
api_url: &str,
|
||||
query_params: Option<&HashMap<String, String>>,
|
||||
event_parser: RealtimeEventParser,
|
||||
session_mode: RealtimeSessionMode,
|
||||
call_id: &str,
|
||||
) -> Result<Url, ApiError> {
|
||||
let mut url = websocket_url_from_api_url(
|
||||
api_url,
|
||||
query_params,
|
||||
/*model*/ None,
|
||||
event_parser,
|
||||
session_mode,
|
||||
)?;
|
||||
url.query_pairs_mut().append_pair("call_id", call_id);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn normalize_realtime_path(url: &mut Url) {
|
||||
let path = url.path().to_string();
|
||||
if path.is_empty() || path == "/" {
|
||||
@@ -1094,6 +1186,22 @@ mod tests {
|
||||
assert_eq!(url.as_str(), "wss://example.com/v1/realtime");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_url_for_call_id_joins_existing_realtime_session() {
|
||||
let url = websocket_url_from_api_url_for_call(
|
||||
"https://api.openai.com/v1",
|
||||
/*query_params*/ None,
|
||||
RealtimeEventParser::RealtimeV2,
|
||||
RealtimeSessionMode::Conversational,
|
||||
"rtc_test",
|
||||
)
|
||||
.expect("build ws url");
|
||||
assert_eq!(
|
||||
url.as_str(),
|
||||
"wss://api.openai.com/v1/realtime?call_id=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");
|
||||
|
||||
Reference in New Issue
Block a user