From 1fdb695e427b08c77e28e12c4d90cb46df49f7c9 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 8 Apr 2026 22:11:30 -0700 Subject: [PATCH] Default realtime startup to v2 model (#17183) - Default realtime sessions to v2 and gpt-realtime-1.5 when no override is configured. - Add Op::RealtimeConversationStart integration coverage and keep v1-specific tests explicit. --------- Co-authored-by: Codex --- .../tests/suite/v2/realtime_conversation.rs | 4 +- codex-rs/core/src/config/config_tests.rs | 29 ++++++ codex-rs/core/src/config/mod.rs | 13 +-- codex-rs/core/src/realtime_conversation.rs | 8 +- codex-rs/core/tests/common/test_codex.rs | 2 + .../core/tests/suite/realtime_conversation.rs | 89 ++++++++++++++++--- codex-rs/protocol/src/protocol.rs | 2 +- 7 files changed, 128 insertions(+), 19 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index 2ca4e39ab..f1f60be45 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -973,7 +973,7 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { Some("multipart/form-data; boundary=codex-realtime-call-boundary") ); let body = String::from_utf8(request.body).context("multipart body should be utf-8")?; - let session = r#"{"tool_choice":"auto","type":"realtime","instructions":"backend prompt\n\nstartup context","output_modalities":["audio"],"audio":{"input":{"format":{"type":"audio/pcm","rate":24000},"noise_reduction":{"type":"near_field"},"turn_detection":{"type":"server_vad","interrupt_response":true,"create_response":true}},"output":{"format":{"type":"audio/pcm","rate":24000},"voice":"marin"}},"tools":[{"type":"function","name":"codex","description":"Delegate a request to Codex and return the final result to the user. Use this as the default action. If the user asks to do something next, later, after this, or once current work finishes, call this tool so the work is actually queued instead of merely promising to do it later.","parameters":{"type":"object","properties":{"prompt":{"type":"string","description":"The user request to delegate to Codex."}},"required":["prompt"],"additionalProperties":false}}]}"#; + let session = r#"{"tool_choice":"auto","type":"realtime","model":"gpt-realtime-1.5","instructions":"backend prompt\n\nstartup context","output_modalities":["audio"],"audio":{"input":{"format":{"type":"audio/pcm","rate":24000},"noise_reduction":{"type":"near_field"},"turn_detection":{"type":"server_vad","interrupt_response":true,"create_response":true}},"output":{"format":{"type":"audio/pcm","rate":24000},"voice":"marin"}},"tools":[{"type":"function","name":"codex","description":"Delegate a request to Codex and return the final result to the user. Use this as the default action. If the user asks to do something next, later, after this, or once current work finishes, call this tool so the work is actually queued instead of merely promising to do it later.","parameters":{"type":"object","properties":{"prompt":{"type":"string","description":"The user request to delegate to Codex."}},"required":["prompt"],"additionalProperties":false}}]}"#; assert_eq!( body, format!( @@ -1709,7 +1709,7 @@ fn assert_call_create_multipart( } fn v1_session_create_json() -> &'static str { - r#"{"audio":{"input":{"format":{"type":"audio/pcm","rate":24000}},"output":{"voice":"cove"}},"type":"quicksilver","instructions":"backend prompt\n\nstartup context"}"# + r#"{"audio":{"input":{"format":{"type":"audio/pcm","rate":24000}},"output":{"voice":"cove"}},"type":"quicksilver","model":"gpt-realtime-1.5","instructions":"backend prompt\n\nstartup context"}"# } fn create_config_toml( diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 478f8182f..dab42da75 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -6478,6 +6478,35 @@ experimental_realtime_ws_model = "realtime-test-model" Ok(()) } +#[test] +fn realtime_config_partial_table_uses_realtime_defaults() -> std::io::Result<()> { + let cfg: ConfigToml = toml::from_str( + r#" +[realtime] +voice = "marin" +"#, + ) + .expect("TOML deserialization should succeed"); + + let codex_home = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.realtime, + RealtimeConfig { + version: RealtimeWsVersion::V2, + session_type: RealtimeWsMode::Conversational, + transport: RealtimeTransport::Websocket, + voice: Some(RealtimeVoice::Marin), + } + ); + Ok(()) +} + #[test] fn realtime_loads_from_config_toml() -> std::io::Result<()> { let cfg: ConfigToml = toml::from_str( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index ebb98e48a..eac8f9e36 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2089,11 +2089,14 @@ impl Config { experimental_realtime_ws_model: cfg.experimental_realtime_ws_model, realtime: cfg .realtime - .map_or_else(RealtimeConfig::default, |realtime| RealtimeConfig { - version: realtime.version.unwrap_or_default(), - session_type: realtime.session_type.unwrap_or_default(), - transport: realtime.transport.unwrap_or_default(), - voice: realtime.voice, + .map_or_else(RealtimeConfig::default, |realtime| { + let defaults = RealtimeConfig::default(); + RealtimeConfig { + version: realtime.version.unwrap_or(defaults.version), + session_type: realtime.session_type.unwrap_or(defaults.session_type), + transport: realtime.transport.unwrap_or(defaults.transport), + voice: realtime.voice, + } }), experimental_realtime_ws_backend_prompt: cfg.experimental_realtime_ws_backend_prompt, experimental_realtime_ws_startup_context: cfg.experimental_realtime_ws_startup_context, diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 38cb0c273..ea719f5f8 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -61,6 +61,7 @@ const USER_TEXT_IN_QUEUE_CAPACITY: usize = 64; const HANDOFF_OUT_QUEUE_CAPACITY: usize = 64; const OUTPUT_EVENTS_QUEUE_CAPACITY: usize = 256; const REALTIME_STARTUP_CONTEXT_TOKEN_BUDGET: usize = 5_000; +const DEFAULT_REALTIME_MODEL: &str = "gpt-realtime-1.5"; const ACTIVE_RESPONSE_CONFLICT_ERROR_PREFIX: &str = "Conversation already has an active response in progress:"; @@ -572,7 +573,12 @@ pub(crate) async fn build_realtime_session_config( (false, true) => prompt, (false, false) => format!("{prompt}\n\n{startup_context}"), }; - let model = config.experimental_realtime_ws_model.clone(); + let model = Some( + config + .experimental_realtime_ws_model + .clone() + .unwrap_or_else(|| DEFAULT_REALTIME_MODEL.to_string()), + ); let event_parser = match config.realtime.version { RealtimeWsVersion::V1 => RealtimeEventParser::V1, RealtimeWsVersion::V2 => RealtimeEventParser::RealtimeV2, diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index ad62db4d7..eaa9f8e2b 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -32,6 +32,7 @@ use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; +use codex_protocol::protocol::RealtimeConversationVersion as RealtimeWsVersion; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; @@ -453,6 +454,7 @@ impl TestCodexBuilder { config.model_provider.base_url = Some(base_url_clone); config.model_provider.supports_websockets = true; config.experimental_realtime_ws_model = Some("realtime-test-model".to_string()); + config.realtime.version = RealtimeWsVersion::V1; })); let test_env = TestEnv::local().await?; Box::pin(self.build_with_home_and_base_url(base_url, home, /*resume_from*/ None, test_env)) diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index fb4f87155..0f68af428 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -360,6 +360,65 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_start_defaults_to_v2_and_gpt_realtime_1_5() -> Result<()> { + skip_if_no_network!(Ok(())); + + let api_server = start_mock_server().await; + let realtime_server = start_websocket_server(vec![vec![vec![]]]).await; + let realtime_base_url = realtime_server.uri().to_string(); + let mut builder = test_codex().with_config(move |config| { + config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.experimental_realtime_ws_startup_context = Some(String::new()); + }); + let test = builder.build(&api_server).await?; + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + prompt: Some(Some("backend prompt".to_string())), + session_id: None, + transport: None, + 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 + .unwrap_or_else(|err: ErrorEvent| panic!("conversation start failed: {err:?}")); + + assert!( + realtime_server + .wait_for_handshakes(/*expected*/ 1, Duration::from_secs(2)) + .await + ); + + let session_update = realtime_server + .wait_for_request(/*connection_index*/ 0, /*request_index*/ 0) + .await; + let body = session_update.body_json(); + assert_eq!( + json!({ + "startedVersion": started.version, + "handshakeUri": realtime_server.single_handshake().uri(), + "voice": body["session"]["audio"]["output"]["voice"], + "instructions": body["session"]["instructions"], + }), + json!({ + "startedVersion": RealtimeConversationVersion::V2, + "handshakeUri": "/v1/realtime?model=gpt-realtime-1.5", + "voice": "marin", + "instructions": "backend prompt", + }) + ); + + realtime_server.shutdown().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn conversation_webrtc_start_posts_generated_session() -> Result<()> { skip_if_no_network!(Ok(())); @@ -393,6 +452,7 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> { config.experimental_realtime_ws_model = Some("realtime-test-model".to_string()); config.experimental_realtime_ws_startup_context = Some("startup context".to_string()); config.experimental_realtime_ws_base_url = Some(realtime_ws_base_url); + config.realtime.version = RealtimeWsVersion::V1; }); let test = builder.build(&server).await?; @@ -718,6 +778,7 @@ async fn conversation_start_connect_failure_emits_realtime_error_only() -> Resul let server = start_websocket_server(vec![]).await; let mut builder = test_codex().with_config(|config| { config.experimental_realtime_ws_base_url = Some("http://127.0.0.1:1".to_string()); + config.realtime.version = RealtimeWsVersion::V1; }); let test = builder.build_with_websocket_server(&server).await?; @@ -908,6 +969,7 @@ async fn conversation_uses_experimental_realtime_ws_base_url_override() -> Resul let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build_with_websocket_server(&startup_server).await?; @@ -1179,16 +1241,11 @@ async fn conversation_uses_configured_realtime_voice() -> Result<()> { async fn conversation_rejects_voice_for_wrong_realtime_version() -> Result<()> { skip_if_no_network!(Ok(())); - let server = start_websocket_server(vec![vec![]]).await; + let api_server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { config.realtime.version = RealtimeWsVersion::V2; }); - let test = builder.build_with_websocket_server(&server).await?; - assert!( - server - .wait_for_handshakes(/*expected*/ 1, Duration::from_secs(2)) - .await - ); + let test = builder.build(&api_server).await?; test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { @@ -1207,9 +1264,6 @@ async fn conversation_rejects_voice_for_wrong_realtime_version() -> Result<()> { }) .await; assert!(error.contains("realtime voice `cove` is not supported for v2")); - - assert_eq!(server.connections().len(), 1); - server.shutdown().await; Ok(()) } @@ -1279,6 +1333,7 @@ async fn conversation_uses_experimental_realtime_ws_startup_context_override() - let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; config.experimental_realtime_ws_backend_prompt = Some("prompt from config".to_string()); config.experimental_realtime_ws_startup_context = Some("custom startup context".to_string()); @@ -1342,6 +1397,7 @@ async fn conversation_disables_realtime_startup_context_with_empty_override() -> let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; config.experimental_realtime_ws_backend_prompt = Some("prompt from config".to_string()); config.experimental_realtime_ws_startup_context = Some(String::new()); } @@ -1404,6 +1460,7 @@ async fn conversation_start_injects_startup_context_from_thread_history() -> Res let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build_with_websocket_server(&startup_server).await?; @@ -1466,6 +1523,7 @@ async fn conversation_startup_context_falls_back_to_workspace_map() -> Result<() let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build_with_websocket_server(&startup_server).await?; @@ -1519,6 +1577,7 @@ async fn conversation_startup_context_is_truncated_and_sent_once_per_start() -> let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build_with_websocket_server(&startup_server).await?; @@ -1607,6 +1666,7 @@ async fn conversation_mirrors_assistant_message_text_to_realtime_handoff() -> Re let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build(&api_server).await?; @@ -1735,6 +1795,7 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() -> let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build_with_streaming_server(&api_server).await?; @@ -1878,6 +1939,7 @@ async fn inbound_handoff_request_starts_turn() -> Result<()> { let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build(&api_server).await?; @@ -1974,6 +2036,7 @@ async fn inbound_handoff_request_uses_active_transcript() -> Result<()> { let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build(&api_server).await?; @@ -2068,6 +2131,7 @@ async fn inbound_handoff_request_clears_active_transcript_after_each_handoff() - let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build(&api_server).await?; @@ -2169,6 +2233,7 @@ async fn inbound_conversation_item_does_not_start_turn_and_still_forwards_audio( let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build(&api_server).await?; @@ -2283,6 +2348,7 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build_with_streaming_server(&api_server).await?; @@ -2427,6 +2493,7 @@ async fn inbound_handoff_request_does_not_block_realtime_event_forwarding() -> R let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build_with_streaming_server(&api_server).await?; @@ -2555,6 +2622,7 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build_with_streaming_server(&api_server).await?; @@ -2698,6 +2766,7 @@ async fn inbound_handoff_request_starts_turn_and_does_not_block_realtime_audio() let realtime_base_url = realtime_server.uri().to_string(); move |config| { config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; } }); let test = builder.build_with_streaming_server(&api_server).await?; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index dd3d1bfca..8083db935 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1649,8 +1649,8 @@ pub struct HookCompletedEvent { #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum RealtimeConversationVersion { - #[default] V1, + #[default] V2, }