diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 4de4da6e4..9096455fa 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1541,6 +1541,10 @@ "experimental_compact_prompt_file": { "$ref": "#/definitions/AbsolutePathBuf" }, + "experimental_realtime_ws_base_url": { + "description": "Experimental / do not use. Overrides only the realtime conversation websocket transport base URL (the `Op::RealtimeConversation` `/ws` connection) without changing normal provider HTTP requests.", + "type": "string" + }, "experimental_use_freeform_apply_patch": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 76ba2fcb2..68bc623e0 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -394,6 +394,11 @@ pub struct Config { /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: String, + /// Experimental / do not use. Overrides only the realtime conversation + /// websocket transport base URL (the `Op::RealtimeConversation` `/ws` + /// connection) without changing normal provider HTTP requests. + pub experimental_realtime_ws_base_url: Option, + /// When set, restricts ChatGPT login to a specific workspace identifier. pub forced_chatgpt_workspace_id: Option, @@ -1119,6 +1124,11 @@ pub struct ConfigToml { /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: Option, + /// Experimental / do not use. Overrides only the realtime conversation + /// websocket transport base URL (the `Op::RealtimeConversation` `/ws` + /// connection) without changing normal provider HTTP requests. + pub experimental_realtime_ws_base_url: Option, + pub projects: Option>, /// Controls the web search tool mode: disabled, cached, or live. @@ -2043,6 +2053,7 @@ impl Config { .chatgpt_base_url .or(cfg.chatgpt_base_url) .unwrap_or("https://chatgpt.com/backend-api/".to_string()), + experimental_realtime_ws_base_url: cfg.experimental_realtime_ws_base_url, forced_chatgpt_workspace_id, forced_login_method, include_apply_patch_tool: include_apply_patch_tool_flag, @@ -4583,6 +4594,7 @@ model_verbosity = "high" model_verbosity: None, personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + experimental_realtime_ws_base_url: None, base_instructions: None, developer_instructions: None, compact_prompt: None, @@ -4702,6 +4714,7 @@ model_verbosity = "high" model_verbosity: None, personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + experimental_realtime_ws_base_url: None, base_instructions: None, developer_instructions: None, compact_prompt: None, @@ -4819,6 +4832,7 @@ model_verbosity = "high" model_verbosity: None, personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + experimental_realtime_ws_base_url: None, base_instructions: None, developer_instructions: None, compact_prompt: None, @@ -4922,6 +4936,7 @@ model_verbosity = "high" model_verbosity: Some(Verbosity::High), personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + experimental_realtime_ws_base_url: None, base_instructions: None, developer_instructions: None, compact_prompt: None, @@ -5708,6 +5723,34 @@ trust_level = "untrusted" ); Ok(()) } + + #[test] + fn experimental_realtime_ws_base_url_loads_from_config_toml() -> std::io::Result<()> { + let cfg: ConfigToml = toml::from_str( + r#" +experimental_realtime_ws_base_url = "http://127.0.0.1:8011" +"#, + ) + .expect("TOML deserialization should succeed"); + + assert_eq!( + cfg.experimental_realtime_ws_base_url.as_deref(), + Some("http://127.0.0.1:8011") + ); + + 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.experimental_realtime_ws_base_url.as_deref(), + Some("http://127.0.0.1:8011") + ); + Ok(()) + } } #[cfg(test)] diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index dfaddd97b..b41b4bd0d 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -169,7 +169,11 @@ pub(crate) async fn handle_start( ) -> CodexResult<()> { let provider = sess.provider().await; let auth = sess.services.auth_manager.auth().await; - let api_provider = provider.to_api_provider(auth.as_ref().map(CodexAuth::auth_mode))?; + let mut api_provider = provider.to_api_provider(auth.as_ref().map(CodexAuth::auth_mode))?; + let config = sess.get_config().await; + if let Some(realtime_ws_base_url) = &config.experimental_realtime_ws_base_url { + api_provider.base_url = realtime_ws_base_url.clone(); + } let requested_session_id = params .session_id diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index a6eccc969..ddc63385d 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -358,3 +358,58 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> { server.shutdown().await; Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_uses_experimental_realtime_ws_base_url_override() -> Result<()> { + skip_if_no_network!(Ok(())); + + let startup_server = start_websocket_server(vec![vec![]]).await; + let realtime_server = start_websocket_server(vec![vec![vec![json!({ + "type": "session.created", + "session": { "id": "sess_override" } + })]]]) + .await; + + let mut builder = test_codex().with_config({ + let realtime_base_url = realtime_server.uri().to_string(); + move |config| { + config.experimental_realtime_ws_base_url = Some(realtime_base_url); + } + }); + let test = builder.build_with_websocket_server(&startup_server).await?; + assert!( + startup_server + .wait_for_handshakes(1, Duration::from_secs(2)) + .await + ); + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + prompt: "backend prompt".to_string(), + session_id: None, + })) + .await?; + + let session_created = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::SessionCreated { session_id }, + }) => Some(session_id.clone()), + _ => None, + }) + .await; + assert_eq!(session_created, "sess_override"); + + let startup_connections = startup_server.connections(); + assert_eq!(startup_connections.len(), 1); + + let realtime_connections = realtime_server.connections(); + assert_eq!(realtime_connections.len(), 1); + assert_eq!( + realtime_connections[0][0].body_json()["type"].as_str(), + Some("session.create") + ); + + startup_server.shutdown().await; + realtime_server.shutdown().await; + Ok(()) +}