From 8e6a6e24d2af7081f2fc5e8a953547df01a40fab Mon Sep 17 00:00:00 2001 From: Reese Date: Wed, 16 Sep 2026 08:31:31 +0000 Subject: [PATCH 1/2] Adds provider transport capability metadata Keeps WebSocket support explicit so custom and proxy providers default to the safer HTTP streaming path. Updates built-in providers, configuration examples, test fixtures, and provider deserialization coverage. --- code-rs/core/src/client.rs | 16 ++++++++++ code-rs/core/src/config.rs | 1 + code-rs/core/src/model_provider_info.rs | 34 +++++++++++++++++++++ code-rs/core/tests/remote_models_manager.rs | 1 + docs/config.md | 3 ++ 5 files changed, 55 insertions(+) diff --git a/code-rs/core/src/client.rs b/code-rs/core/src/client.rs index 5265cca1cf9f..a784ac0c3d33 100644 --- a/code-rs/core/src/client.rs +++ b/code-rs/core/src/client.rs @@ -3296,6 +3296,7 @@ mod tests { stream_max_retries: None, stream_idle_timeout_ms: None, websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, } @@ -3420,6 +3421,7 @@ mod tests { stream_max_retries: None, stream_idle_timeout_ms: None, websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, }; @@ -3470,6 +3472,7 @@ mod tests { stream_max_retries: None, stream_idle_timeout_ms: None, websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, }; @@ -3522,6 +3525,7 @@ mod tests { stream_max_retries: None, stream_idle_timeout_ms: None, websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, }; @@ -3564,6 +3568,7 @@ mod tests { stream_max_retries: None, stream_idle_timeout_ms: None, websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, }; @@ -3714,6 +3719,7 @@ mod tests { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(1000), websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, }; @@ -3803,6 +3809,7 @@ mod tests { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(1000), websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, }; @@ -3851,6 +3858,7 @@ mod tests { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(1000), websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, }; @@ -3905,6 +3913,7 @@ mod tests { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(1000), websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, }; @@ -4002,6 +4011,7 @@ mod tests { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(1000), websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, }; @@ -4111,6 +4121,7 @@ mod tests { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(1000), websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, }; @@ -4363,6 +4374,7 @@ mod tests { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(1000), websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, }; @@ -4435,6 +4447,7 @@ mod tests { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(1000), websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, }; @@ -4471,6 +4484,7 @@ mod tests { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(1000), websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, }; @@ -4504,6 +4518,7 @@ mod tests { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(1000), websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, }; @@ -4537,6 +4552,7 @@ mod tests { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(1000), websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, }; diff --git a/code-rs/core/src/config.rs b/code-rs/core/src/config.rs index 599b6d2b754f..7614d0669acb 100644 --- a/code-rs/core/src/config.rs +++ b/code-rs/core/src/config.rs @@ -2555,6 +2555,7 @@ model_verbosity = "high" stream_max_retries: Some(10), stream_idle_timeout_ms: Some(300_000), websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, }; diff --git a/code-rs/core/src/model_provider_info.rs b/code-rs/core/src/model_provider_info.rs index 61ec87a860a6..f9aee2bc79aa 100644 --- a/code-rs/core/src/model_provider_info.rs +++ b/code-rs/core/src/model_provider_info.rs @@ -135,6 +135,10 @@ pub struct ModelProviderInfo { /// Timeout (in milliseconds) when establishing a websocket transport connection. pub websocket_connect_timeout_ms: Option, + /// Whether this provider supports the Responses API WebSocket transport. + #[serde(default)] + pub supports_websockets: bool, + /// Whether this provider requires some form of standard authentication (API key, ChatGPT token). #[serde(default)] pub requires_openai_auth: bool, @@ -783,6 +787,7 @@ pub fn built_in_model_providers( stream_max_retries: None, stream_idle_timeout_ms: None, websocket_connect_timeout_ms: None, + supports_websockets: true, requires_openai_auth: true, openrouter: None, }, @@ -831,6 +836,7 @@ pub fn create_oss_provider_with_base_url(base_url: &str) -> ModelProviderInfo { stream_max_retries: None, stream_idle_timeout_ms: None, websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, } @@ -878,6 +884,7 @@ base_url = "http://localhost:11434/v1" stream_max_retries: None, stream_idle_timeout_ms: None, websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, }; @@ -911,6 +918,7 @@ query_params = { api-version = "2025-04-01-preview" } stream_max_retries: None, stream_idle_timeout_ms: None, websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, }; @@ -947,6 +955,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } stream_max_retries: None, stream_idle_timeout_ms: None, websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, }; @@ -973,6 +982,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } stream_max_retries: None, stream_idle_timeout_ms: None, websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, } @@ -1009,6 +1019,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } stream_max_retries: None, stream_idle_timeout_ms: None, websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, }; @@ -1046,6 +1057,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } stream_max_retries: None, stream_idle_timeout_ms: None, websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, } @@ -1098,6 +1110,27 @@ args = ["--format=text"] ); } + #[test] + fn custom_provider_websocket_support_defaults_to_false() { + let provider: ModelProviderInfo = toml::from_str( + r#" +name = "Gateway" +wire_api = "responses" +"#, + ) + .unwrap(); + + assert!(!provider.supports_websockets); + } + + #[test] + fn built_in_openai_provider_explicitly_supports_websockets() { + let providers = built_in_model_providers(None); + + assert!(providers["openai"].supports_websockets); + assert!(!providers[BUILT_IN_OSS_MODEL_PROVIDER_ID].supports_websockets); + } + #[test] fn test_deserialize_provider_auth_config_allows_zero_refresh_interval() { let base_dir = tempdir().unwrap(); @@ -1142,6 +1175,7 @@ refresh_interval_ms = 0 stream_max_retries: None, stream_idle_timeout_ms: None, websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, }; diff --git a/code-rs/core/tests/remote_models_manager.rs b/code-rs/core/tests/remote_models_manager.rs index 40fbefd2684c..c98224786f3c 100644 --- a/code-rs/core/tests/remote_models_manager.rs +++ b/code-rs/core/tests/remote_models_manager.rs @@ -59,6 +59,7 @@ fn provider_for(base_url: String) -> ModelProviderInfo { stream_max_retries: Some(0), stream_idle_timeout_ms: Some(5_000), websocket_connect_timeout_ms: None, + supports_websockets: false, requires_openai_auth: false, openrouter: None, } diff --git a/docs/config.md b/docs/config.md index 95b4ae69ec64..0a058eb74ea0 100644 --- a/docs/config.md +++ b/docs/config.md @@ -48,6 +48,9 @@ base_url = "https://api.openai.com/v1" env_key = "OPENAI_API_KEY" # Valid values for wire_api are "chat" and "responses". Defaults to "chat" if omitted. wire_api = "chat" +# Set true only when this provider supports Responses over WebSocket. +# Defaults to false for custom providers. +supports_websockets = false # If necessary, extra query params that need to be added to the URL. # See the Azure example below. query_params = {} From afb73413f1f614cad94beb73b4866b7035034361 Mon Sep 17 00:00:00 2001 From: Reese Date: Wed, 16 Sep 2026 08:31:31 +0000 Subject: [PATCH 2/2] Guards WebSocket transport by provider support Falls back to HTTP streaming when a provider does not advertise WebSocket capability. Uses consistent correlation headers so compatible gateways can preserve session and prompt-cache routing. --- code-rs/core/src/client.rs | 43 +++++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/code-rs/core/src/client.rs b/code-rs/core/src/client.rs index a784ac0c3d33..9d160c977eac 100644 --- a/code-rs/core/src/client.rs +++ b/code-rs/core/src/client.rs @@ -344,7 +344,9 @@ impl ModelClient { } fn active_ws_version_for_prompt(&self, prompt: &Prompt) -> Option { - if self.websockets_disabled.load(Ordering::Relaxed) { + if self.websockets_disabled.load(Ordering::Relaxed) + || !self.provider.supports_websockets + { return None; } @@ -395,6 +397,22 @@ impl ModelClient { )) } + /// Use the session-correlation header names emitted by codex-rs. + /// + /// The older fork used underscore-style `conversation_id`, `session_id`, + /// and `thread_id` headers. OpenAI-compatible gateways may route those + /// legacy names differently from codex-rs's hyphenated headers. + fn apply_responses_session_headers( + &self, + req_builder: reqwest::RequestBuilder, + session_id: &str, + ) -> reqwest::RequestBuilder { + req_builder + .header("x-client-request-id", session_id) + .header("session-id", session_id) + .header("thread-id", session_id) + } + fn current_reasoning_param( &self, family: &ModelFamily, @@ -658,6 +676,12 @@ impl ModelClient { } } WireApi::ResponsesWebsocket => { + if !self.provider.supports_websockets { + warn!( + "responses_websocket transport is disabled for this provider; using responses HTTP stream" + ); + return self.stream_responses(prompt, log_tag).await; + } if self.websockets_disabled.load(Ordering::Relaxed) { warn!( "responses_websocket transport disabled for this session; using responses HTTP stream" @@ -946,10 +970,7 @@ impl ModelClient { if let Some(state) = turn_state.get() { req_builder = req_builder.header(X_CODEX_TURN_STATE_HEADER, state); } - req_builder = req_builder - .header("conversation_id", session_id_str.clone()) - .header("session_id", session_id_str.clone()) - .header("thread_id", session_id_str.clone()); + req_builder = self.apply_responses_session_headers(req_builder, &session_id_str); if let Ok(window_id) = HeaderValue::from_str(&self.current_window_id(session_id)) { req_builder = req_builder.header(X_CODEX_WINDOW_ID_HEADER, window_id); } @@ -1450,11 +1471,8 @@ impl ModelClient { req_builder = req_builder.header(X_CODEX_TURN_STATE_HEADER, state); } - req_builder = req_builder - // Send `conversation_id`/`session_id` so the server can hit the prompt-cache. - .header("conversation_id", session_id_str.clone()) - .header("session_id", session_id_str.clone()) - .header("thread_id", session_id_str.clone()) + req_builder = self + .apply_responses_session_headers(req_builder, &session_id_str) .header(reqwest::header::ACCEPT, "text/event-stream") .json(&payload_json); if let Ok(window_id) = HeaderValue::from_str(&self.current_window_id(session_id)) { @@ -2108,10 +2126,7 @@ impl ModelClient { request = request.header(X_CODEX_WINDOW_ID_HEADER, window_id); } - request = request - .header("conversation_id", session_id_str.clone()) - .header("session_id", session_id_str.clone()) - .header("thread_id", session_id_str.clone()); + request = self.apply_responses_session_headers(request, &session_id_str); if let Some(auth) = auth.as_ref() && auth.mode.is_chatgpt()