Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 45 additions & 14 deletions code-rs/core/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,9 @@ impl ModelClient {
}

fn active_ws_version_for_prompt(&self, prompt: &Prompt) -> Option<ResponsesWebsocketVersion> {
if self.websockets_disabled.load(Ordering::Relaxed) {
if self.websockets_disabled.load(Ordering::Relaxed)
|| !self.provider.supports_websockets
{
return None;
Comment on lines +347 to 350

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add integration coverage for the transport capability gate

The new tests only verify provider deserialization and defaults; none exercises ModelClient::stream. Add request-level integration cases proving that WebSocket-preferring WireApi::Responses and explicit WireApi::ResponsesWebsocket providers go directly to HTTP when unsupported, while supports_websockets = true still selects WebSocket. Without this, the central dispatch behavior—and the compatibility regression for existing explicit WebSocket configurations—can go unnoticed.

AGENTS.md reference: AGENTS.md:L23-L23

Useful? React with 👍 / 👎.

}

Expand Down Expand Up @@ -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)
Comment on lines +411 to +413

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Test the emitted session-correlation headers

No request-level test verifies this header migration across HTTP streaming, WebSocket handshakes, or compaction. Add integration assertions that x-client-request-id, session-id, and thread-id contain the expected session override and that the legacy underscore headers are absent; otherwise a typo or a missed call site can silently break gateway routing and prompt-cache correlation.

Useful? React with 👍 / 👎.

}

fn current_reasoning_param(
&self,
family: &ModelFamily,
Expand Down Expand Up @@ -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;
Comment on lines +679 to +683

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve explicit Responses WebSocket configurations

Existing custom providers with wire_api = "responses_websocket" but no new supports_websockets setting deserialize that capability as false, so this branch silently switches them to HTTP. This breaks WebSocket-only providers that do not expose POST /responses; it also invalidates the configuration emitted by scripts/mock_responses_websocket_server.py:161-165. Treat the explicit WebSocket wire API as capability, or provide a compatibility-preserving migration.

Useful? React with 👍 / 👎.

}
if self.websockets_disabled.load(Ordering::Relaxed) {
warn!(
"responses_websocket transport disabled for this session; using responses HTTP stream"
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -3296,6 +3311,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,
}
Expand Down Expand Up @@ -3420,6 +3436,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,
};
Expand Down Expand Up @@ -3470,6 +3487,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,
};
Expand Down Expand Up @@ -3522,6 +3540,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,
};
Expand Down Expand Up @@ -3564,6 +3583,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,
};
Expand Down Expand Up @@ -3714,6 +3734,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,
};
Expand Down Expand Up @@ -3803,6 +3824,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,
};
Expand Down Expand Up @@ -3851,6 +3873,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,
};
Expand Down Expand Up @@ -3905,6 +3928,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,
};
Expand Down Expand Up @@ -4002,6 +4026,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,
};
Expand Down Expand Up @@ -4111,6 +4136,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,
};
Expand Down Expand Up @@ -4363,6 +4389,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,
};
Expand Down Expand Up @@ -4435,6 +4462,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,
};
Expand Down Expand Up @@ -4471,6 +4499,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,
};
Expand Down Expand Up @@ -4504,6 +4533,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,
};
Expand Down Expand Up @@ -4537,6 +4567,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,
};
Expand Down
1 change: 1 addition & 0 deletions code-rs/core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
34 changes: 34 additions & 0 deletions code-rs/core/src/model_provider_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ pub struct ModelProviderInfo {
/// Timeout (in milliseconds) when establishing a websocket transport connection.
pub websocket_connect_timeout_ms: Option<u64>,

/// 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,
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
};
Expand All @@ -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,
}
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
};
Expand Down
1 change: 1 addition & 0 deletions code-rs/core/tests/remote_models_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
3 changes: 3 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down