Support attaching to existing realtime calls (#39876)

## Why

Clients that create and negotiate realtime calls themselves need Codex to join
those calls without creating another call or overwriting the client-owned
session configuration.

## What changed

- Add the `existingCall` transport to `thread/realtime/start`, accepting a
  client-provided `callId` and optional `realtimeSessionId`.
- Attach Codex over the call's sideband WebSocket without SDP negotiation or a
  session update, and reconnect that sideband while preserving transcript
  state.
- Default existing calls to realtime v1, support v1 and v3, and reject v2 or
  options that would reconfigure the client-owned session.
- Encode call IDs as a single URL path segment for v3 sideband connections.

## Testing

Add protocol, app-server, core, reconnection, authentication-header, and URL
encoding coverage for existing-call attachments.

GitOrigin-RevId: 2046b70566f98efa55381bb1e461ea3cc68256c5
This commit is contained in:
Zahan Malkani
2026-08-21 06:41:28 +00:00
committed by copyberry
parent 2aaefa32b0
commit 536f86e5cc
18 changed files with 765 additions and 34 deletions
+14
View File
@@ -686,6 +686,20 @@ impl ModelClient {
})
}
pub(crate) async fn realtime_sideband_headers(
&self,
mut extra_headers: ApiHeaderMap,
) -> Result<ApiHeaderMap> {
let client_setup = self.current_client_setup().await?;
if let Some(header_value) = self.generate_attestation_header_for().await {
extra_headers.insert(X_OAI_ATTESTATION_HEADER, header_value);
}
extra_headers.extend(sideband_websocket_auth_headers(
client_setup.api_auth.as_ref(),
));
Ok(extra_headers)
}
/// Builds memory summaries for each provided normalized raw memory.
///
/// This is a unary call (no streaming) to `/v1/memories/trace_summarize`.
+19
View File
@@ -974,6 +974,25 @@ async fn websocket_handshake_includes_attestation_for_chatgpt_codex_responses()
assert_eq!(attestation_calls.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn existing_call_sideband_headers_include_attestation() {
let (model_client, attestation_calls) =
model_client_with_counting_attestation(/*include_attestation*/ true);
let headers = model_client
.realtime_sideband_headers(http::HeaderMap::new())
.await
.expect("existing call sideband headers should build");
assert_eq!(
headers
.get(crate::attestation::X_OAI_ATTESTATION_HEADER)
.and_then(|value| value.to_str().ok()),
Some("v1.header-1"),
);
assert_eq!(attestation_calls.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn non_chatgpt_codex_endpoints_omit_attestation_generation() {
let (model_client, attestation_calls) =
+70 -9
View File
@@ -22,6 +22,7 @@ use codex_api::RealtimeEventParser;
use codex_api::RealtimeSessionConfig;
use codex_api::RealtimeSessionMode;
use codex_api::RealtimeWebsocketClient;
use codex_api::RealtimeWebsocketConnection;
use codex_api::RealtimeWebsocketEvents;
use codex_api::RealtimeWebsocketWriter;
use codex_api::build_session_headers;
@@ -80,6 +81,7 @@ use tracing::info;
use tracing::warn;
mod bem;
mod existing_call;
mod sideband;
use self::bem::ChannelParser as BemChannelParser;
@@ -478,6 +480,7 @@ struct RealtimeStart {
session_config: RealtimeSessionConfig,
model_client: ModelClient,
sdp: Option<String>,
existing_call_id: Option<String>,
}
struct RealtimeStartOutput {
@@ -550,6 +553,7 @@ impl RealtimeConversationManager {
session_config,
model_client,
sdp,
existing_call_id,
} = start;
let event_parser = session_config.event_parser;
let session_kind = match event_parser {
@@ -612,6 +616,7 @@ impl RealtimeConversationManager {
session_config,
call_id: call.call_id,
sideband_headers: call.sideband_headers,
session_initialization: RealtimeSidebandSessionInitialization::CoreCreated,
input_channels,
events_tx,
handoff_state: handoff.clone(),
@@ -622,6 +627,24 @@ impl RealtimeConversationManager {
stop_token: stop_token.clone(),
});
(task, Some(call.sdp))
} else if let Some(call_id) = existing_call_id {
let task = existing_call::attach(existing_call::ExistingCallAttachment {
client,
model_client,
session_config,
call_id,
extra_headers: extra_headers.unwrap_or_default(),
input_channels,
events_tx,
handoff_state: handoff.clone(),
session_kind,
event_parser,
realtime_active: Arc::clone(&realtime_active),
transcript_tail_flush,
stop_token: stop_token.clone(),
})
.await?;
(task, None)
} else {
let connection = client
.connect(
@@ -1173,19 +1196,46 @@ async fn prepare_realtime_start(
};
let version = params.version.unwrap_or(match &transport {
ConversationStartTransport::Websocket => config.realtime.version,
ConversationStartTransport::Webrtc { .. } => RealtimeWsVersion::V1,
ConversationStartTransport::Webrtc { .. }
| ConversationStartTransport::ExistingCall { .. } => RealtimeWsVersion::V1,
});
if matches!(transport, ConversationStartTransport::Webrtc { .. }) {
validate_avas_webrtc_start(version, config.realtime.session_type)?;
match &transport {
ConversationStartTransport::Webrtc { .. } => {
validate_avas_webrtc_start(version, config.realtime.session_type)?;
}
ConversationStartTransport::ExistingCall { .. } => {
if version == RealtimeWsVersion::V2 {
return Err(CodexErr::InvalidRequest(
"AVAS realtime calls require realtime v1 or v3".to_string(),
));
}
if params.include_startup_context
|| params.prompt.is_some()
|| !params.initial_items.is_empty()
|| params.model.is_some()
|| params.voice.is_some()
|| params.delegation_ack_filler.is_some()
{
return Err(CodexErr::InvalidRequest(
"existing realtime calls do not support session configuration options"
.to_string(),
));
}
}
ConversationStartTransport::Websocket => {}
}
let configured_voice = match (&transport, params.version) {
(ConversationStartTransport::Webrtc { .. }, None) => ConfiguredRealtimeVoice::Ignore,
(ConversationStartTransport::ExistingCall { .. }, _)
| (ConversationStartTransport::Webrtc { .. }, None) => ConfiguredRealtimeVoice::Ignore,
(ConversationStartTransport::Webrtc { .. } | ConversationStartTransport::Websocket, _) => {
ConfiguredRealtimeVoice::Use
}
};
let session_config =
let mut session_config =
build_realtime_session_config(sess, &params, version, configured_voice).await?;
if matches!(&transport, ConversationStartTransport::ExistingCall { .. }) {
session_config.session_id = params.realtime_session_id.clone();
}
let requested_realtime_session_id = session_config.session_id.clone();
let event_parser = session_config.event_parser;
let originator = sess.originator().await;
@@ -1199,7 +1249,8 @@ async fn prepare_realtime_start(
originator.as_str(),
)?
}
ConversationStartTransport::Webrtc { .. } => {
ConversationStartTransport::Webrtc { .. }
| ConversationStartTransport::ExistingCall { .. } => {
realtime_request_headers(
requested_realtime_session_id.as_deref(),
/*api_key*/ None,
@@ -1454,9 +1505,10 @@ async fn handle_start_inner(
transport,
} = prepared_start;
info!("starting realtime conversation");
let sdp = match transport {
ConversationStartTransport::Websocket => None,
ConversationStartTransport::Webrtc { sdp } => Some(sdp),
let (sdp, existing_call_id) = match transport {
ConversationStartTransport::Websocket => (None, None),
ConversationStartTransport::Webrtc { sdp } => (Some(sdp), None),
ConversationStartTransport::ExistingCall { call_id } => (None, Some(call_id)),
};
let mode_instructions = RealtimeModeInstructions {
start: realtime_start_instructions,
@@ -1476,6 +1528,7 @@ async fn handle_start_inner(
session_config,
model_client: sess.services.model_client.clone(),
sdp,
existing_call_id,
};
let start_output = sess.conversation.start(start, mode_instructions).await?;
@@ -1757,6 +1810,7 @@ struct RealtimeWebrtcSidebandInputTask {
session_config: RealtimeSessionConfig,
call_id: String,
sideband_headers: HeaderMap,
session_initialization: RealtimeSidebandSessionInitialization,
input_channels: RealtimeInputChannels,
events_tx: Sender<RealtimeEvent>,
handoff_state: RealtimeHandoffState,
@@ -1767,6 +1821,13 @@ struct RealtimeWebrtcSidebandInputTask {
stop_token: CancellationToken,
}
enum RealtimeSidebandSessionInitialization {
CoreCreated,
ExistingCall {
initial_connection: Option<RealtimeWebsocketConnection>,
},
}
async fn run_realtime_input_task(
input: RealtimeInputTask,
pending_outbound: Option<Box<RealtimePendingOutbound>>,
@@ -0,0 +1,90 @@
use super::RealtimeHandoffState;
use super::RealtimeInputChannels;
use super::RealtimeSessionKind;
use super::RealtimeSidebandSessionInitialization;
use super::RealtimeTranscriptTailFlush;
use super::RealtimeWebrtcSidebandInputTask;
use super::spawn_webrtc_sideband_input_task;
use crate::client::ModelClient;
use async_channel::Sender;
use codex_api::RealtimeEvent;
use codex_api::RealtimeEventParser;
use codex_api::RealtimeSessionConfig;
use codex_api::RealtimeTranscriptState;
use codex_api::RealtimeWebsocketClient;
use codex_api::map_api_error;
use codex_login::default_client::default_headers;
use codex_protocol::error::Result as CodexResult;
use http::HeaderMap;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
pub(super) struct ExistingCallAttachment {
pub(super) client: RealtimeWebsocketClient,
pub(super) model_client: ModelClient,
pub(super) session_config: RealtimeSessionConfig,
pub(super) call_id: String,
pub(super) extra_headers: HeaderMap,
pub(super) input_channels: RealtimeInputChannels,
pub(super) events_tx: Sender<RealtimeEvent>,
pub(super) handoff_state: RealtimeHandoffState,
pub(super) session_kind: RealtimeSessionKind,
pub(super) event_parser: RealtimeEventParser,
pub(super) realtime_active: Arc<AtomicBool>,
pub(super) transcript_tail_flush: RealtimeTranscriptTailFlush,
pub(super) stop_token: CancellationToken,
}
pub(super) async fn attach(attachment: ExistingCallAttachment) -> CodexResult<JoinHandle<()>> {
let ExistingCallAttachment {
client,
model_client,
session_config,
call_id,
extra_headers,
input_channels,
events_tx,
handoff_state,
session_kind,
event_parser,
realtime_active,
transcript_tail_flush,
stop_token,
} = attachment;
let sideband_headers = model_client
.realtime_sideband_headers(extra_headers)
.await?;
let transcript_state = RealtimeTranscriptState::default();
let connection = client
.connect_existing_call_sideband(
session_config.clone(),
&call_id,
sideband_headers.clone(),
default_headers(),
transcript_state,
)
.await
.map_err(map_api_error)?;
Ok(spawn_webrtc_sideband_input_task(
RealtimeWebrtcSidebandInputTask {
client,
session_config,
call_id,
sideband_headers,
session_initialization: RealtimeSidebandSessionInitialization::ExistingCall {
initial_connection: Some(connection),
},
input_channels,
events_tx,
handoff_state,
session_kind,
event_parser,
realtime_active,
transcript_tail_flush,
stop_token,
},
))
}
@@ -1,5 +1,6 @@
use super::RealtimeInputTask;
use super::RealtimeInputTaskExit;
use super::RealtimeSidebandSessionInitialization;
use super::RealtimeWebrtcSidebandInputTask;
use super::flush_realtime_transcript_tail;
use super::report_realtime_transport_loss;
@@ -31,6 +32,7 @@ pub(super) fn spawn_webrtc_sideband_input_task(
session_config,
call_id,
sideband_headers,
mut session_initialization,
input_channels,
events_tx,
handoff_state,
@@ -45,18 +47,44 @@ pub(super) fn spawn_webrtc_sideband_input_task(
let mut reconnecting = false;
let mut rapid_disconnects = 0_u32;
let mut pending_outbound = None;
let transcript_state = RealtimeTranscriptState::default();
let transcript_state = match &session_initialization {
RealtimeSidebandSessionInitialization::ExistingCall {
initial_connection: Some(connection),
} => connection.events().transcript_state(),
RealtimeSidebandSessionInitialization::CoreCreated
| RealtimeSidebandSessionInitialization::ExistingCall {
initial_connection: None,
} => RealtimeTranscriptState::default(),
};
while realtime_active.load(Ordering::Relaxed) {
let connection = match tokio::select! {
biased;
_ = stop_token.cancelled() => break,
connection = client.connect_webrtc_sideband(
session_config.clone(),
&call_id,
sideband_headers.clone(),
default_headers(),
transcript_state.clone(),
) => connection,
connection = async {
match &mut session_initialization {
RealtimeSidebandSessionInitialization::CoreCreated => {
client.connect_webrtc_sideband(
session_config.clone(),
&call_id,
sideband_headers.clone(),
default_headers(),
transcript_state.clone(),
).await
}
RealtimeSidebandSessionInitialization::ExistingCall { initial_connection } => {
match initial_connection.take() {
Some(connection) => Ok(connection),
None => client.connect_existing_call_sideband(
session_config.clone(),
&call_id,
sideband_headers.clone(),
default_headers(),
transcript_state.clone(),
).await,
}
}
}
} => connection,
} {
Ok(connection) => connection,
Err(err) => {
@@ -1,6 +1,7 @@
use anyhow::Context;
use anyhow::Result;
use chrono::Utc;
use codex_config::config_toml::RealtimeWsMode;
use codex_config::config_toml::RealtimeWsVersion;
use codex_core::TurnInputRequest;
use codex_core::test_support::auth_manager_from_auth;
@@ -54,6 +55,7 @@ use std::process::Command;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use test_case::test_case;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
use tokio::sync::oneshot;
@@ -507,6 +509,85 @@ async fn conversation_start_defaults_to_v2_and_gpt_realtime_1_5() -> Result<()>
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_existing_call_attaches_without_creating_another_call() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let realtime_server = start_websocket_server(vec![vec![vec![]]]).await;
let realtime_ws_base_url = realtime_server.uri().to_string();
let mut builder = test_codex().with_config(move |config| {
config.experimental_realtime_ws_backend_prompt = Some("backend prompt".to_string());
config.experimental_realtime_ws_base_url = Some(realtime_ws_base_url);
config.realtime.session_type = RealtimeWsMode::Transcription;
});
let test = builder.build_with_auto_env(&server).await?;
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
client_managed_handoffs: false,
delegation_ack_filler: None,
flush_transcript_tail_on_session_end: false,
codex_responses_as_items: false,
codex_response_item_prefix: None,
codex_response_handoff_mode:
codex_protocol::protocol::CodexResponseHandoffMode::Thinking,
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: false,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: None,
realtime_session_id: None,
transport: Some(ConversationStartTransport::ExistingCall {
call_id: "rtc_existing".to_string(),
}),
version: Some(RealtimeConversationVersion::V3),
voice: None,
}))
.await?;
let started = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationStarted(started) => Some(Ok(started.clone())),
EventMsg::Error(err) => Some(Err(err.clone())),
_ => None,
})
.await
.expect("existing call sideband attachment failed");
assert_eq!(
(started.version, started.realtime_session_id),
(RealtimeConversationVersion::V3, None)
);
let handshake = realtime_server.single_handshake();
assert_eq!(handshake.uri(), "/v1/live/rtc_existing");
assert_eq!(
handshake.header("authorization").as_deref(),
Some("Bearer dummy")
);
assert_eq!(handshake.header("x-session-id"), None);
assert!(
server
.received_requests()
.await
.context("mock server should record requests")?
.iter()
.all(|request| !request.url.path().ends_with("/realtime/calls")),
"attaching to an existing call must not create another realtime call"
);
test.codex.submit(Op::RealtimeConversationClose).await?;
let _closed = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationClosed(closed) => Some(closed.clone()),
_ => None,
})
.await;
realtime_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_webrtc_frameless_chatgpt_sends_codex_headers_to_backend() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -823,10 +904,22 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> {
Ok(())
}
#[test_case(
ConversationStartTransport::Webrtc { sdp: "v=offer\r\n".to_string() };
"core-created webrtc"
)]
#[test_case(
ConversationStartTransport::ExistingCall { call_id: "rtc_reconnect".to_string() };
"client-created existing call"
)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_webrtc_live_reconnects_sideband_after_unclean_disconnect() -> Result<()> {
async fn conversation_webrtc_live_reconnects_sideband_after_unclean_disconnect(
transport: ConversationStartTransport,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let attaches_existing_call =
matches!(&transport, ConversationStartTransport::ExistingCall { .. });
let server = start_mock_server().await;
Mock::given(method("POST"))
.and(path("/v1/live"))
@@ -979,7 +1072,7 @@ async fn conversation_webrtc_live_reconnects_sideband_after_unclean_disconnect()
config.experimental_realtime_ws_base_url = Some(realtime_ws_base_url);
config.realtime.version = RealtimeWsVersion::V3;
});
let test = builder.build(&server).await?;
let test = builder.build_with_auto_env(&server).await?;
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
@@ -993,15 +1086,17 @@ async fn conversation_webrtc_live_reconnects_sideband_after_unclean_disconnect()
codex_response_handoff_channel_prefixes: None,
model: None,
output_modality: RealtimeOutputModality::Audio,
include_startup_context: true,
include_startup_context: !attaches_existing_call,
initial_items: Vec::new(),
realtime_start_instructions: None,
realtime_end_instructions: None,
prompt: Some(Some("backend prompt".to_string())),
realtime_session_id: None,
transport: Some(ConversationStartTransport::Webrtc {
sdp: "v=offer\r\n".to_string(),
}),
prompt: if attaches_existing_call {
None
} else {
Some(Some("backend prompt".to_string()))
},
realtime_session_id: attaches_existing_call.then(|| "sess_client_owned".to_string()),
transport: Some(transport),
version: Some(RealtimeConversationVersion::V3),
voice: None,
}))