mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
9798eb377a
## Why Users and support need a single command that captures the local Codex runtime, configuration, auth, terminal, network, and state shape without asking the user to know which diagnostic depth to choose first. `codex doctor` now runs the useful checks by default and makes the detailed human output the default because the command is usually run when someone already needs context. The command also targets concrete support failure modes we have seen while iterating on the design: - update-target mismatches like #21956, where the installed package manager target can differ from the running executable - terminal and multiplexer issues that depend on `TERM`, tmux/zellij state, color handling, and TTY metadata - provider-specific HTTP/WebSocket connectivity, including ChatGPT WebSocket handshakes and API-key/provider endpoint reachability - local state/log SQLite integrity problems and large rollout directories - feedback reports that need an attached, redacted diagnostic snapshot without asking the user to run a second command ## What Changed - Adds `codex doctor` as a grouped CLI diagnostic report with default detailed output and `--summary` for the compact view. - Adds stable report sections for Environment, Configuration, Updates, Connectivity, and Background Server, plus a top Notes block that promotes anomalies such as available updates, large rollout directories, optional MCP issues, and mixed auth signals. - Adds runtime provenance, install consistency, bundled/system search readiness, terminal/multiplexer metadata, `config.toml` parse status, auth mode details, sandbox details, feature flag summaries, update cache/latest-version state, app-server daemon state, SQLite integrity checks, rollout statistics, and provider-aware network diagnostics. - Adds ChatGPT WebSocket diagnostics that report the negotiated HTTP upgrade as `HTTP 101 Switching Protocols` and include timeout, DNS, auth, and provider context in detailed output. - Makes reachability provider-aware: API-key OpenAI setups check the API endpoint, ChatGPT auth checks the ChatGPT path, and custom/AWS/local providers check configured HTTP endpoints when available. - Adds structured, redacted JSON output where `checks` is keyed by check id and `details` is a key/value object for support tooling. - Integrates doctor with feedback uploads by attaching a best-effort `codex-doctor-report.json` report and adding derived Sentry tags for overall status and failing/warning checks. - Updates the TUI feedback consent copy so users can see that the doctor report is included when logs/diagnostics are uploaded. - Updates the CLI bug issue template to ask reporters for `codex doctor --json` and render pasted reports as JSON. ## Example Output The examples below are sanitized from local smoke runs with `--no-color` so the structure is reviewable in plain text. ### `codex doctor` ```text Codex Doctor v0.0.0 · macos-aarch64 Notes ↑ updates 0.130.0 available (current 0.0.0, dismissed 0.128.0) ⚠ rollouts 1,526 active files · 2.53 GB on disk ⚠ mcp MCP configuration has optional issues ⚠ auth mixed auth signals: ChatGPT login plus API key env var; HTTP reachability uses API-key mode ───────────────────────────────────────────────────────────── Environment ✓ runtime local debug build version 0.0.0 install method other commit unknown executable ~/code/codex.fcoury-doct…x-rs/target/debug/codex ✓ install consistent context other managed by npm: no · bun: no · package root — PATH entries (2) ~/.local/share/mise/installs/node/24/bin/codex ~/.local/share/mise/shims/codex ✓ search ripgrep 15.1.0 (system, `rg`) ✓ terminal Ghostty 1.3.2-main-+b0f827665 · tmux 3.6a · TERM=xterm-256color terminal Ghostty TERM_PROGRAM ghostty terminal version 1.3.2-main-+b0f827665 TERM xterm-256color multiplexer tmux 3.6a tmux extended-keys on tmux allow-passthrough on tmux set-clipboard on ✓ state databases healthy CODEX_HOME ~/.codex (dir) state DB ~/.codex/state_5.sqlite (file) · integrity ok log DB ~/.codex/logs_2.sqlite (file) · integrity ok active rollouts 1,526 files · 2.53 GB (avg 1.70 MB) archived rollouts 8 files · 3.84 MB (avg 491.11 KB) Configuration ✓ config loaded model gpt-5.5 · openai cwd ~/code/codex.fcoury-doctor/codex-rs config.toml ~/.codex/config.toml config.toml parse ok MCP servers 1 feature flags 36 enabled · 7 overridden (full list with --all) overrides code_mode, code_mode_only, memories, chronicle, goals, remote_control, prevent_idle_sleep ✓ auth auth is configured auth storage mode File auth file ~/.codex/auth.json auth env vars present OPENAI_API_KEY stored auth mode chatgpt stored API key false stored ChatGPT tokens true stored agent identity false ⚠ mcp MCP configuration has optional issues — Set the missing MCP env vars or disable the affected server. configured servers 1 disabled servers 0 streamable_http servers 1 optional reachability openaiDeveloperDocs: https://developers.openai.com/mcp (HEAD connect failed; GET connect failed) ✓ sandbox restricted fs + restricted network · approval OnRequest approval policy OnRequest filesystem sandbox restricted network sandbox restricted Connectivity ✓ network network-related environment looks readable ✓ websocket connected (HTTP 101 Switching Protocols) · 15s timeout model provider openai provider name OpenAI wire API responses supports websockets true connect timeout 15000 ms auth mode chatgpt endpoint wss://chatgpt.com/backend-api/<redacted> DNS 2 IPv4, 2 IPv6, first IPv6 handshake result HTTP 101 Switching Protocols ✗ reachability one or more required provider endpoints are unreachable over HTTP — Check proxy, VPN, firewall, DNS, and custom CA configuration. reachability mode API key auth openai API https://api.openai.com/v1 connect failed (required) Background Server ○ app-server not running (ephemeral mode) ───────────────────────────────────────────────────────────── 11 ok · 1 idle · 4 notes · 1 warn · 1 fail failed --summary compact output --all expand truncated lists --json redacted report ``` ### `codex doctor --summary` ```text Codex Doctor v0.0.0 · macos-aarch64 Notes ↑ updates 0.130.0 available (current 0.0.0, dismissed 0.128.0) ⚠ rollouts 1,526 active files · 2.53 GB on disk ⚠ mcp MCP configuration has optional issues ⚠ auth mixed auth signals: ChatGPT login plus API key env var; HTTP reachability uses API-key mode ───────────────────────────────────────────────────────────── Environment ✓ runtime local debug build ✓ install consistent ✓ search ripgrep 15.1.0 (system, `rg`) ✓ terminal Ghostty 1.3.2-main-+b0f827665 · tmux 3.6a · TERM=xterm-256color ✓ state databases healthy Configuration ✓ config loaded ✓ auth auth is configured ⚠ mcp MCP configuration has optional issues — Set the missing MCP env vars or disable the affected server. ✓ sandbox restricted fs + restricted network · approval OnRequest Updates ✓ updates update configuration is locally consistent Connectivity ✓ network network-related environment looks readable ✓ websocket connected (HTTP 101 Switching Protocols) · 15s timeout ✗ reachability one or more required provider endpoints are unreachable over HTTP — Check proxy, VPN, firewall, DNS, and custom CA configuration. Background Server ○ app-server not running (ephemeral mode) ───────────────────────────────────────────────────────────── 11 ok · 1 idle · 4 notes · 1 warn · 1 fail failed Run codex doctor without --summary for detailed diagnostics. --all expand truncated lists --json redacted report ``` ### `codex doctor --json` shape ```json { "schema_version": 1, "overall_status": "fail", "checks": { "runtime.provenance": { "id": "runtime.provenance", "category": "Environment", "status": "ok", "summary": "local debug build", "details": { "version": "0.0.0", "install method": "other", "commit": "unknown" } }, "sandbox.helpers": { "id": "sandbox.helpers", "category": "Configuration", "status": "ok", "summary": "restricted fs + restricted network · approval OnRequest", "details": { "approval policy": "OnRequest", "filesystem sandbox": "restricted", "network sandbox": "restricted" } } } } ``` ### `/feedback` new sentry attachment <img width="938" height="798" alt="CleanShot 2026-05-13 at 15 36 14" src="https://github.com/user-attachments/assets/715e62e0-d7b4-4fea-a35a-fd5d5d33c4c0" /> ### New section in CLI issue template <img width="1164" height="435" alt="CleanShot 2026-05-13 at 15 47 24" src="https://github.com/user-attachments/assets/9081dc25-a28c-4afa-8ba1-e299c2b4031d" /> ## How to Test 1. Run `cargo run --bin codex -- doctor --no-color`. 2. Confirm the detailed report is the default and includes promoted Notes, grouped sections, terminal details, state DB integrity, rollout stats, provider reachability, WebSocket diagnostics, and app-server status. 3. Run `cargo run --bin codex -- doctor --summary --no-color`. 4. Confirm the compact view keeps the same sections and summary counts but omits detailed key/value rows. 5. Run `cargo run --bin codex -- doctor --json`. 6. Confirm the output is redacted JSON, `checks` is an object keyed by check id, and each check's `details` is a key/value object. 7. Preview the CLI bug issue template and confirm the `Codex doctor report` field appears after the terminal field, asks for `codex doctor --json`, and renders pasted output as JSON. 8. Start a feedback flow that includes logs. 9. Confirm the upload consent copy lists `codex-doctor-report.json` alongside the log attachments. Targeted tests: - `cargo test -p codex-cli doctor` - `cargo test -p codex-app-server doctor_report_tags_summarize_status_counts` - `cargo test -p codex-feedback` - `cargo test -p codex-tui feedback_view` - `just argument-comment-lint` - `git diff --check`
997 lines
34 KiB
Rust
997 lines
34 KiB
Rust
use crate::auth::SharedAuthProvider;
|
|
use crate::common::ResponseEvent;
|
|
use crate::common::ResponseProcessedWsRequest;
|
|
use crate::common::ResponseStream;
|
|
use crate::common::ResponsesWsRequest;
|
|
use crate::error::ApiError;
|
|
use crate::provider::Provider;
|
|
use crate::rate_limits::parse_rate_limit_event;
|
|
use crate::sse::ResponsesStreamEvent;
|
|
use crate::sse::process_responses_event;
|
|
use crate::telemetry::WebsocketTelemetry;
|
|
use codex_client::TransportError;
|
|
use codex_client::maybe_build_rustls_client_config_with_custom_ca;
|
|
use codex_utils_rustls_provider::ensure_rustls_crypto_provider;
|
|
use futures::SinkExt;
|
|
use futures::StreamExt;
|
|
use http::HeaderMap;
|
|
use http::HeaderName;
|
|
use http::HeaderValue;
|
|
use http::StatusCode;
|
|
use serde::Deserialize;
|
|
use serde_json::Value;
|
|
use serde_json::map::Map as JsonMap;
|
|
use std::sync::Arc;
|
|
use std::sync::OnceLock;
|
|
use std::time::Duration;
|
|
use tokio::net::TcpStream;
|
|
use tokio::sync::Mutex;
|
|
use tokio::sync::mpsc;
|
|
use tokio::sync::oneshot;
|
|
use tokio::time::Instant;
|
|
use tokio_tungstenite::MaybeTlsStream;
|
|
use tokio_tungstenite::WebSocketStream;
|
|
use tokio_tungstenite::connect_async_tls_with_config;
|
|
use tokio_tungstenite::tungstenite::Error as WsError;
|
|
use tokio_tungstenite::tungstenite::Message;
|
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
|
use tokio_tungstenite::tungstenite::protocol::CloseFrame;
|
|
use tracing::Instrument;
|
|
use tracing::Span;
|
|
use tracing::debug;
|
|
use tracing::error;
|
|
use tracing::info;
|
|
use tracing::instrument;
|
|
use tracing::trace;
|
|
use tungstenite::extensions::ExtensionsConfig;
|
|
use tungstenite::extensions::compression::deflate::DeflateConfig;
|
|
use tungstenite::protocol::WebSocketConfig;
|
|
use url::Url;
|
|
|
|
struct WsStream {
|
|
tx_command: mpsc::Sender<WsCommand>,
|
|
rx_message: mpsc::UnboundedReceiver<Result<Message, WsError>>,
|
|
pump_task: tokio::task::JoinHandle<()>,
|
|
}
|
|
|
|
enum WsCommand {
|
|
Send {
|
|
message: Message,
|
|
tx_result: oneshot::Sender<Result<(), WsError>>,
|
|
},
|
|
}
|
|
|
|
impl WsStream {
|
|
fn new(inner: WebSocketStream<MaybeTlsStream<TcpStream>>) -> Self {
|
|
let (tx_command, mut rx_command) = mpsc::channel::<WsCommand>(32);
|
|
let (tx_message, rx_message) = mpsc::unbounded_channel::<Result<Message, WsError>>();
|
|
|
|
let pump_task = tokio::spawn(async move {
|
|
let mut inner = inner;
|
|
loop {
|
|
tokio::select! {
|
|
command = rx_command.recv() => {
|
|
let Some(command) = command else {
|
|
break;
|
|
};
|
|
match command {
|
|
WsCommand::Send { message, tx_result } => {
|
|
let result = inner.send(message).await;
|
|
let should_break = result.is_err();
|
|
let _ = tx_result.send(result);
|
|
if should_break {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
message = inner.next() => {
|
|
let Some(message) = message else {
|
|
break;
|
|
};
|
|
match message {
|
|
Ok(Message::Ping(payload)) => {
|
|
if let Err(err) = inner.send(Message::Pong(payload)).await {
|
|
let _ = tx_message.send(Err(err));
|
|
break;
|
|
}
|
|
}
|
|
Ok(Message::Pong(_)) => {}
|
|
Ok(message @ (Message::Text(_)
|
|
| Message::Binary(_)
|
|
| Message::Close(_)
|
|
| Message::Frame(_))) => {
|
|
let is_close = matches!(message, Message::Close(_));
|
|
if tx_message.send(Ok(message)).is_err() {
|
|
break;
|
|
}
|
|
if is_close {
|
|
break;
|
|
}
|
|
}
|
|
Err(err) => {
|
|
let _ = tx_message.send(Err(err));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
Self {
|
|
tx_command,
|
|
rx_message,
|
|
pump_task,
|
|
}
|
|
}
|
|
|
|
async fn request(
|
|
&self,
|
|
make_command: impl FnOnce(oneshot::Sender<Result<(), WsError>>) -> WsCommand,
|
|
) -> Result<(), WsError> {
|
|
let (tx_result, rx_result) = oneshot::channel();
|
|
if self.tx_command.send(make_command(tx_result)).await.is_err() {
|
|
return Err(WsError::ConnectionClosed);
|
|
}
|
|
rx_result.await.unwrap_or(Err(WsError::ConnectionClosed))
|
|
}
|
|
|
|
async fn send(&self, message: Message) -> Result<(), WsError> {
|
|
self.request(|tx_result| WsCommand::Send { message, tx_result })
|
|
.await
|
|
}
|
|
|
|
async fn next(&mut self) -> Option<Result<Message, WsError>> {
|
|
self.rx_message.recv().await
|
|
}
|
|
}
|
|
|
|
impl Drop for WsStream {
|
|
fn drop(&mut self) {
|
|
self.pump_task.abort();
|
|
}
|
|
}
|
|
|
|
const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state";
|
|
const X_MODELS_ETAG_HEADER: &str = "x-models-etag";
|
|
const X_REASONING_INCLUDED_HEADER: &str = "x-reasoning-included";
|
|
const OPENAI_MODEL_HEADER: &str = "openai-model";
|
|
const WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE: &str = "websocket_connection_limit_reached";
|
|
const WEBSOCKET_CONNECTION_LIMIT_REACHED_MESSAGE: &str = "Responses websocket connection limit reached (60 minutes). Create a new websocket connection to continue.";
|
|
|
|
pub struct ResponsesWebsocketConnection {
|
|
stream: Arc<Mutex<Option<WsStream>>>,
|
|
// TODO (pakrym): is this the right place for timeout?
|
|
idle_timeout: Duration,
|
|
server_reasoning_included: bool,
|
|
models_etag: Option<String>,
|
|
server_model: Option<String>,
|
|
telemetry: Option<Arc<dyn WebsocketTelemetry>>,
|
|
}
|
|
|
|
impl std::fmt::Debug for ResponsesWebsocketConnection {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("ResponsesWebsocketConnection")
|
|
.field("stream", &"<ws-stream>")
|
|
.field("idle_timeout", &self.idle_timeout)
|
|
.field("server_reasoning_included", &self.server_reasoning_included)
|
|
.field("models_etag", &self.models_etag)
|
|
.field("server_model", &self.server_model)
|
|
.field("telemetry", &self.telemetry.as_ref().map(|_| "<telemetry>"))
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl ResponsesWebsocketConnection {
|
|
fn new(
|
|
stream: WsStream,
|
|
idle_timeout: Duration,
|
|
server_reasoning_included: bool,
|
|
models_etag: Option<String>,
|
|
server_model: Option<String>,
|
|
telemetry: Option<Arc<dyn WebsocketTelemetry>>,
|
|
) -> Self {
|
|
Self {
|
|
stream: Arc::new(Mutex::new(Some(stream))),
|
|
idle_timeout,
|
|
server_reasoning_included,
|
|
models_etag,
|
|
server_model,
|
|
telemetry,
|
|
}
|
|
}
|
|
|
|
pub async fn is_closed(&self) -> bool {
|
|
self.stream.lock().await.is_none()
|
|
}
|
|
|
|
#[instrument(
|
|
name = "responses_websocket.send_response_processed",
|
|
level = "info",
|
|
skip_all,
|
|
fields(transport = "responses_websocket", api.path = "responses")
|
|
)]
|
|
#[expect(
|
|
clippy::await_holding_invalid_type,
|
|
reason = "the guard serializes exclusive use of the websocket while sending a request frame"
|
|
)]
|
|
pub async fn send_response_processed(&self, response_id: String) -> Result<(), ApiError> {
|
|
let request =
|
|
ResponsesWsRequest::ResponseProcessed(ResponseProcessedWsRequest { response_id });
|
|
let request_body = serde_json::to_value(&request).map_err(|err| {
|
|
ApiError::Stream(format!("failed to encode websocket request: {err}"))
|
|
})?;
|
|
|
|
let mut guard = self.stream.lock().await;
|
|
let Some(ws_stream) = guard.as_mut() else {
|
|
return Err(ApiError::Stream(
|
|
"websocket connection is closed".to_string(),
|
|
));
|
|
};
|
|
|
|
send_websocket_request(
|
|
ws_stream,
|
|
request_body,
|
|
self.idle_timeout,
|
|
self.telemetry.as_ref(),
|
|
/*connection_reused*/ true,
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[instrument(
|
|
name = "responses_websocket.stream_request",
|
|
level = "info",
|
|
skip_all,
|
|
fields(transport = "responses_websocket", api.path = "responses")
|
|
)]
|
|
pub async fn stream_request(
|
|
&self,
|
|
request: ResponsesWsRequest,
|
|
connection_reused: bool,
|
|
) -> Result<ResponseStream, ApiError> {
|
|
let (tx_event, rx_event) =
|
|
mpsc::channel::<std::result::Result<ResponseEvent, ApiError>>(1600);
|
|
let stream = Arc::clone(&self.stream);
|
|
let idle_timeout = self.idle_timeout;
|
|
let server_reasoning_included = self.server_reasoning_included;
|
|
let models_etag = self.models_etag.clone();
|
|
let server_model = self.server_model.clone();
|
|
let telemetry = self.telemetry.clone();
|
|
let request_body = serde_json::to_value(&request).map_err(|err| {
|
|
ApiError::Stream(format!("failed to encode websocket request: {err}"))
|
|
})?;
|
|
|
|
let current_span = Span::current();
|
|
tokio::spawn(
|
|
#[expect(
|
|
clippy::await_holding_invalid_type,
|
|
reason = "the guard serializes exclusive use of the websocket stream for the lifetime of the response stream"
|
|
)]
|
|
async move {
|
|
if let Some(model) = server_model {
|
|
let _ = tx_event.send(Ok(ResponseEvent::ServerModel(model))).await;
|
|
}
|
|
if let Some(etag) = models_etag {
|
|
let _ = tx_event.send(Ok(ResponseEvent::ModelsEtag(etag))).await;
|
|
}
|
|
if server_reasoning_included {
|
|
let _ = tx_event
|
|
.send(Ok(ResponseEvent::ServerReasoningIncluded(true)))
|
|
.await;
|
|
}
|
|
let mut guard = stream.lock().await;
|
|
let result = {
|
|
let Some(ws_stream) = guard.as_mut() else {
|
|
let _ = tx_event
|
|
.send(Err(ApiError::Stream(
|
|
"websocket connection is closed".to_string(),
|
|
)))
|
|
.await;
|
|
return;
|
|
};
|
|
|
|
run_websocket_response_stream(
|
|
ws_stream,
|
|
tx_event.clone(),
|
|
request_body,
|
|
idle_timeout,
|
|
telemetry,
|
|
connection_reused,
|
|
)
|
|
.await
|
|
};
|
|
|
|
if let Err(err) = result {
|
|
// A terminal stream error should reach the caller immediately. Waiting for a
|
|
// graceful close handshake here can stall indefinitely and mask the error.
|
|
let failed_stream = guard.take();
|
|
drop(guard);
|
|
drop(failed_stream);
|
|
let _ = tx_event.send(Err(err)).await;
|
|
}
|
|
}
|
|
.instrument(current_span),
|
|
);
|
|
|
|
Ok(ResponseStream {
|
|
rx_event,
|
|
upstream_request_id: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Client for connecting to the Responses WebSocket endpoint for one provider.
|
|
pub struct ResponsesWebsocketClient {
|
|
provider: Provider,
|
|
auth: SharedAuthProvider,
|
|
}
|
|
|
|
/// Close frame information captured by a handshake probe.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ResponsesWebsocketClose {
|
|
/// WebSocket close code returned by the server.
|
|
pub code: String,
|
|
/// Human-readable close reason returned by the server.
|
|
pub reason: String,
|
|
}
|
|
|
|
/// Result of a handshake-only Responses WebSocket probe.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ResponsesWebsocketProbe {
|
|
/// Redacted by callers before displaying or serializing support reports.
|
|
pub url: String,
|
|
/// HTTP status returned by the successful WebSocket upgrade.
|
|
pub status: StatusCode,
|
|
/// Whether the server reported reasoning support in the upgrade response.
|
|
pub reasoning_included: bool,
|
|
/// Whether the server returned a model catalog ETag in the upgrade response.
|
|
pub models_etag_present: bool,
|
|
/// Whether the server returned a server-selected model in the upgrade response.
|
|
pub server_model_present: bool,
|
|
/// Close frame received immediately after upgrade, when one arrives quickly.
|
|
pub immediate_close: Option<ResponsesWebsocketClose>,
|
|
}
|
|
|
|
impl ResponsesWebsocketClient {
|
|
/// Creates a Responses WebSocket client for an already-resolved provider and auth source.
|
|
pub fn new(provider: Provider, auth: SharedAuthProvider) -> Self {
|
|
Self { provider, auth }
|
|
}
|
|
|
|
#[instrument(
|
|
name = "responses_websocket.connect",
|
|
level = "info",
|
|
skip_all,
|
|
fields(transport = "responses_websocket", api.path = "responses")
|
|
)]
|
|
pub async fn connect(
|
|
&self,
|
|
extra_headers: HeaderMap,
|
|
default_headers: HeaderMap,
|
|
turn_state: Option<Arc<OnceLock<String>>>,
|
|
telemetry: Option<Arc<dyn WebsocketTelemetry>>,
|
|
) -> Result<ResponsesWebsocketConnection, ApiError> {
|
|
let ws_url = self
|
|
.provider
|
|
.websocket_url_for_path("responses")
|
|
.map_err(|err| ApiError::Stream(format!("failed to build websocket URL: {err}")))?;
|
|
|
|
let mut headers =
|
|
merge_request_headers(&self.provider.headers, extra_headers, default_headers);
|
|
self.auth.add_auth_headers(&mut headers);
|
|
|
|
let (stream, _status, server_reasoning_included, models_etag, server_model) =
|
|
connect_websocket(ws_url, headers, turn_state.clone()).await?;
|
|
Ok(ResponsesWebsocketConnection::new(
|
|
stream,
|
|
self.provider.stream_idle_timeout,
|
|
server_reasoning_included,
|
|
models_etag,
|
|
server_model,
|
|
telemetry,
|
|
))
|
|
}
|
|
|
|
/// Opens a WebSocket connection long enough to validate the upgrade response.
|
|
///
|
|
/// The probe uses the same URL construction, headers, authentication, TLS,
|
|
/// and custom-CA path as a real Responses WebSocket connection, but it does
|
|
/// not send a request frame. After the HTTP 101 upgrade succeeds, it waits
|
|
/// briefly for an immediate server close frame so diagnostics can distinguish
|
|
/// a usable connection from a policy rejection that closes right away.
|
|
pub async fn probe_handshake(
|
|
&self,
|
|
extra_headers: HeaderMap,
|
|
default_headers: HeaderMap,
|
|
immediate_close_timeout: Duration,
|
|
) -> Result<ResponsesWebsocketProbe, ApiError> {
|
|
let ws_url = self
|
|
.provider
|
|
.websocket_url_for_path("responses")
|
|
.map_err(|err| ApiError::Stream(format!("failed to build websocket URL: {err}")))?;
|
|
|
|
let mut headers =
|
|
merge_request_headers(&self.provider.headers, extra_headers, default_headers);
|
|
self.auth.add_auth_headers(&mut headers);
|
|
|
|
let (mut stream, status, reasoning_included, models_etag, server_model) =
|
|
connect_websocket(ws_url.clone(), headers, /*turn_state*/ None).await?;
|
|
let immediate_close = tokio::time::timeout(immediate_close_timeout, stream.next())
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
.transpose()
|
|
.map_err(|err| {
|
|
ApiError::Stream(format!("failed to read websocket probe event: {err}"))
|
|
})?
|
|
.and_then(immediate_close_from_message);
|
|
|
|
Ok(ResponsesWebsocketProbe {
|
|
url: ws_url.to_string(),
|
|
status,
|
|
reasoning_included,
|
|
models_etag_present: models_etag.is_some(),
|
|
server_model_present: server_model.is_some(),
|
|
immediate_close,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn immediate_close_from_message(message: Message) -> Option<ResponsesWebsocketClose> {
|
|
let Message::Close(frame) = message else {
|
|
return None;
|
|
};
|
|
frame.map(close_frame_to_probe)
|
|
}
|
|
|
|
fn close_frame_to_probe(frame: CloseFrame) -> ResponsesWebsocketClose {
|
|
ResponsesWebsocketClose {
|
|
code: frame.code.to_string(),
|
|
reason: frame.reason.to_string(),
|
|
}
|
|
}
|
|
|
|
fn merge_request_headers(
|
|
provider_headers: &HeaderMap,
|
|
extra_headers: HeaderMap,
|
|
default_headers: HeaderMap,
|
|
) -> HeaderMap {
|
|
let mut headers = provider_headers.clone();
|
|
headers.extend(extra_headers);
|
|
for (name, value) in &default_headers {
|
|
if let http::header::Entry::Vacant(entry) = headers.entry(name) {
|
|
entry.insert(value.clone());
|
|
}
|
|
}
|
|
headers
|
|
}
|
|
|
|
async fn connect_websocket(
|
|
url: Url,
|
|
headers: HeaderMap,
|
|
turn_state: Option<Arc<OnceLock<String>>>,
|
|
) -> Result<(WsStream, StatusCode, bool, Option<String>, Option<String>), ApiError> {
|
|
ensure_rustls_crypto_provider();
|
|
info!("connecting to websocket: {url}");
|
|
|
|
let mut request = url
|
|
.as_str()
|
|
.into_client_request()
|
|
.map_err(|err| ApiError::Stream(format!("failed to build websocket request: {err}")))?;
|
|
request.headers_mut().extend(headers);
|
|
|
|
// Secure websocket traffic needs the same custom-CA policy as reqwest-based HTTPS traffic.
|
|
// If a Codex-specific CA bundle is configured, build an explicit rustls connector so this
|
|
// websocket path does not fall back to tungstenite's default native-roots-only behavior.
|
|
let connector = maybe_build_rustls_client_config_with_custom_ca()
|
|
.map_err(|err| ApiError::Stream(format!("failed to configure websocket TLS: {err}")))?
|
|
.map(tokio_tungstenite::Connector::Rustls);
|
|
|
|
let response = connect_async_tls_with_config(
|
|
request,
|
|
Some(websocket_config()),
|
|
false, // `false` means "do not disable Nagle", which is tungstenite's recommended default.
|
|
connector,
|
|
)
|
|
.await;
|
|
|
|
let (stream, response) = match response {
|
|
Ok((stream, response)) => {
|
|
info!(
|
|
"successfully connected to websocket: {url}, headers: {:?}",
|
|
response.headers()
|
|
);
|
|
(stream, response)
|
|
}
|
|
Err(err) => {
|
|
error!("failed to connect to websocket: {err}, url: {url}");
|
|
return Err(map_ws_error(err, &url));
|
|
}
|
|
};
|
|
|
|
let reasoning_included = response.headers().contains_key(X_REASONING_INCLUDED_HEADER);
|
|
let models_etag = response
|
|
.headers()
|
|
.get(X_MODELS_ETAG_HEADER)
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(ToString::to_string);
|
|
let server_model = response
|
|
.headers()
|
|
.get(OPENAI_MODEL_HEADER)
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(ToString::to_string);
|
|
if let Some(turn_state) = turn_state
|
|
&& let Some(header_value) = response
|
|
.headers()
|
|
.get(X_CODEX_TURN_STATE_HEADER)
|
|
.and_then(|value| value.to_str().ok())
|
|
{
|
|
let _ = turn_state.set(header_value.to_string());
|
|
}
|
|
Ok((
|
|
WsStream::new(stream),
|
|
response.status(),
|
|
reasoning_included,
|
|
models_etag,
|
|
server_model,
|
|
))
|
|
}
|
|
|
|
fn websocket_config() -> WebSocketConfig {
|
|
let mut extensions = ExtensionsConfig::default();
|
|
extensions.permessage_deflate = Some(DeflateConfig::default());
|
|
|
|
let mut config = WebSocketConfig::default();
|
|
config.extensions = extensions;
|
|
config
|
|
}
|
|
|
|
fn map_ws_error(err: WsError, url: &Url) -> ApiError {
|
|
match err {
|
|
WsError::Http(response) => {
|
|
let status = response.status();
|
|
let headers = response.headers().clone();
|
|
let body = response
|
|
.body()
|
|
.as_ref()
|
|
.and_then(|bytes| String::from_utf8(bytes.clone()).ok());
|
|
ApiError::Transport(TransportError::Http {
|
|
status,
|
|
url: Some(url.to_string()),
|
|
headers: Some(headers),
|
|
body,
|
|
})
|
|
}
|
|
WsError::ConnectionClosed | WsError::AlreadyClosed => {
|
|
ApiError::Stream("websocket closed".to_string())
|
|
}
|
|
WsError::Io(err) => ApiError::Transport(TransportError::Network(err.to_string())),
|
|
other => ApiError::Transport(TransportError::Network(other.to_string())),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct WrappedWebsocketError {
|
|
code: Option<String>,
|
|
message: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct WrappedWebsocketErrorEvent {
|
|
#[serde(rename = "type")]
|
|
kind: String,
|
|
#[serde(alias = "status_code")]
|
|
status: Option<u16>,
|
|
#[serde(default)]
|
|
error: Option<WrappedWebsocketError>,
|
|
#[serde(default)]
|
|
headers: Option<JsonMap<String, Value>>,
|
|
}
|
|
|
|
fn parse_wrapped_websocket_error_event(payload: &str) -> Option<WrappedWebsocketErrorEvent> {
|
|
let event: WrappedWebsocketErrorEvent = serde_json::from_str(payload).ok()?;
|
|
if event.kind != "error" {
|
|
return None;
|
|
}
|
|
Some(event)
|
|
}
|
|
|
|
fn map_wrapped_websocket_error_event(
|
|
event: WrappedWebsocketErrorEvent,
|
|
original_payload: String,
|
|
) -> Option<ApiError> {
|
|
let WrappedWebsocketErrorEvent {
|
|
status,
|
|
error,
|
|
headers,
|
|
..
|
|
} = event;
|
|
|
|
if let Some(error) = error.as_ref()
|
|
&& let Some(code) = error.code.as_deref()
|
|
&& code == WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE
|
|
{
|
|
return Some(ApiError::Retryable {
|
|
message: error
|
|
.message
|
|
.clone()
|
|
.unwrap_or_else(|| WEBSOCKET_CONNECTION_LIMIT_REACHED_MESSAGE.to_string()),
|
|
delay: None,
|
|
});
|
|
}
|
|
|
|
let status = StatusCode::from_u16(status?).ok()?;
|
|
if status.is_success() {
|
|
return None;
|
|
}
|
|
|
|
Some(ApiError::Transport(TransportError::Http {
|
|
status,
|
|
url: None,
|
|
headers: headers.map(json_headers_to_http_headers),
|
|
body: Some(original_payload),
|
|
}))
|
|
}
|
|
|
|
fn json_headers_to_http_headers(headers: JsonMap<String, Value>) -> HeaderMap {
|
|
let mut mapped = HeaderMap::new();
|
|
for (name, value) in headers {
|
|
let Ok(header_name) = HeaderName::from_bytes(name.as_bytes()) else {
|
|
continue;
|
|
};
|
|
let Some(header_value) = json_header_value(value) else {
|
|
continue;
|
|
};
|
|
mapped.insert(header_name, header_value);
|
|
}
|
|
mapped
|
|
}
|
|
|
|
fn json_header_value(value: Value) -> Option<HeaderValue> {
|
|
let value = match value {
|
|
Value::String(value) => value,
|
|
Value::Number(value) => value.to_string(),
|
|
Value::Bool(value) => value.to_string(),
|
|
_ => return None,
|
|
};
|
|
HeaderValue::from_str(&value).ok()
|
|
}
|
|
|
|
async fn run_websocket_response_stream(
|
|
ws_stream: &mut WsStream,
|
|
tx_event: mpsc::Sender<std::result::Result<ResponseEvent, ApiError>>,
|
|
request_body: Value,
|
|
idle_timeout: Duration,
|
|
telemetry: Option<Arc<dyn WebsocketTelemetry>>,
|
|
connection_reused: bool,
|
|
) -> Result<(), ApiError> {
|
|
let mut last_server_model: Option<String> = None;
|
|
send_websocket_request(
|
|
ws_stream,
|
|
request_body,
|
|
idle_timeout,
|
|
telemetry.as_ref(),
|
|
connection_reused,
|
|
)
|
|
.await?;
|
|
|
|
loop {
|
|
let poll_start = Instant::now();
|
|
let response = tokio::time::timeout(idle_timeout, ws_stream.next())
|
|
.await
|
|
.map_err(|_| ApiError::Stream("idle timeout waiting for websocket".into()));
|
|
if let Some(t) = telemetry.as_ref() {
|
|
t.on_ws_event(&response, poll_start.elapsed());
|
|
}
|
|
let message = match response {
|
|
Ok(Some(Ok(msg))) => msg,
|
|
Ok(Some(Err(err))) => {
|
|
return Err(ApiError::Stream(err.to_string()));
|
|
}
|
|
Ok(None) => {
|
|
return Err(ApiError::Stream(
|
|
"stream closed before response.completed".into(),
|
|
));
|
|
}
|
|
Err(err) => {
|
|
return Err(err);
|
|
}
|
|
};
|
|
|
|
match message {
|
|
Message::Text(text) => {
|
|
trace!("websocket event: {text}");
|
|
if let Some(wrapped_error) = parse_wrapped_websocket_error_event(&text)
|
|
&& let Some(error) =
|
|
map_wrapped_websocket_error_event(wrapped_error, text.to_string())
|
|
{
|
|
return Err(error);
|
|
}
|
|
|
|
let event = match serde_json::from_str::<ResponsesStreamEvent>(&text) {
|
|
Ok(event) => event,
|
|
Err(err) => {
|
|
debug!("failed to parse websocket event: {err}, data: {text}");
|
|
continue;
|
|
}
|
|
};
|
|
let model_verifications = event.model_verifications();
|
|
if event.kind() == "codex.rate_limits" {
|
|
if let Some(snapshot) = parse_rate_limit_event(&text) {
|
|
let _ = tx_event.send(Ok(ResponseEvent::RateLimits(snapshot))).await;
|
|
}
|
|
continue;
|
|
}
|
|
if let Some(model) = event.response_model()
|
|
&& last_server_model.as_deref() != Some(model.as_str())
|
|
{
|
|
let _ = tx_event
|
|
.send(Ok(ResponseEvent::ServerModel(model.clone())))
|
|
.await;
|
|
last_server_model = Some(model);
|
|
}
|
|
if let Some(verifications) = model_verifications
|
|
&& tx_event
|
|
.send(Ok(ResponseEvent::ModelVerifications(verifications)))
|
|
.await
|
|
.is_err()
|
|
{
|
|
return Err(ApiError::Stream(
|
|
"response event consumer dropped".to_string(),
|
|
));
|
|
}
|
|
match process_responses_event(event) {
|
|
Ok(Some(event)) => {
|
|
let is_completed = matches!(event, ResponseEvent::Completed { .. });
|
|
let _ = tx_event.send(Ok(event)).await;
|
|
if is_completed {
|
|
break;
|
|
}
|
|
}
|
|
Ok(None) => {}
|
|
Err(error) => {
|
|
return Err(error.into_api_error());
|
|
}
|
|
}
|
|
}
|
|
Message::Binary(_) => {
|
|
return Err(ApiError::Stream("unexpected binary websocket event".into()));
|
|
}
|
|
Message::Close(_) => {
|
|
return Err(ApiError::Stream(
|
|
"websocket closed by server before response.completed".into(),
|
|
));
|
|
}
|
|
Message::Frame(_) => {}
|
|
Message::Ping(_) | Message::Pong(_) => {}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn send_websocket_request(
|
|
ws_stream: &WsStream,
|
|
request_body: Value,
|
|
idle_timeout: Duration,
|
|
telemetry: Option<&Arc<dyn WebsocketTelemetry>>,
|
|
connection_reused: bool,
|
|
) -> Result<(), ApiError> {
|
|
let request_text = match serde_json::to_string(&request_body) {
|
|
Ok(text) => text,
|
|
Err(err) => {
|
|
return Err(ApiError::Stream(format!(
|
|
"failed to encode websocket request: {err}"
|
|
)));
|
|
}
|
|
};
|
|
trace!("websocket request: {request_text}");
|
|
|
|
let request_start = Instant::now();
|
|
let result = tokio::time::timeout(
|
|
idle_timeout,
|
|
ws_stream.send(Message::Text(request_text.into())),
|
|
)
|
|
.await
|
|
.map_err(|_| ApiError::Stream("idle timeout sending websocket request".into()))
|
|
.and_then(|result| {
|
|
result.map_err(|err| ApiError::Stream(format!("failed to send websocket request: {err}")))
|
|
});
|
|
|
|
if let Some(t) = telemetry.as_ref() {
|
|
t.on_ws_request(
|
|
request_start.elapsed(),
|
|
result.as_ref().err(),
|
|
connection_reused,
|
|
);
|
|
}
|
|
|
|
result?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn websocket_config_enables_permessage_deflate() {
|
|
let config = websocket_config();
|
|
assert!(config.extensions.permessage_deflate.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_wrapped_websocket_error_event_maps_to_transport_http() {
|
|
let payload = json!({
|
|
"type": "error",
|
|
"status": 429,
|
|
"error": {
|
|
"type": "usage_limit_reached",
|
|
"message": "The usage limit has been reached",
|
|
"plan_type": "pro",
|
|
"resets_at": 1738888888
|
|
},
|
|
"headers": {
|
|
"x-codex-primary-used-percent": "100.0",
|
|
"x-codex-primary-window-minutes": 15
|
|
}
|
|
})
|
|
.to_string();
|
|
|
|
let wrapped_error = parse_wrapped_websocket_error_event(&payload)
|
|
.expect("expected websocket error payload to be parsed");
|
|
let api_error = map_wrapped_websocket_error_event(wrapped_error, payload)
|
|
.expect("expected websocket error payload to map to ApiError");
|
|
|
|
let ApiError::Transport(TransportError::Http {
|
|
status,
|
|
headers,
|
|
body,
|
|
..
|
|
}) = api_error
|
|
else {
|
|
panic!("expected ApiError::Transport(Http)");
|
|
};
|
|
|
|
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
|
|
let headers = headers.expect("expected headers");
|
|
assert_eq!(
|
|
headers
|
|
.get("x-codex-primary-used-percent")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("100.0")
|
|
);
|
|
assert_eq!(
|
|
headers
|
|
.get("x-codex-primary-window-minutes")
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some("15")
|
|
);
|
|
let body = body.expect("expected body");
|
|
assert!(body.contains("usage_limit_reached"));
|
|
assert!(body.contains("The usage limit has been reached"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_wrapped_websocket_error_event_ignores_non_error_payloads() {
|
|
let payload = json!({
|
|
"type": "response.created",
|
|
"response": {
|
|
"id": "resp-1"
|
|
}
|
|
})
|
|
.to_string();
|
|
|
|
let wrapped_error = parse_wrapped_websocket_error_event(&payload);
|
|
assert!(wrapped_error.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_wrapped_websocket_error_event_with_status_maps_invalid_request() {
|
|
let payload = json!({
|
|
"type": "error",
|
|
"status": 400,
|
|
"error": {
|
|
"type": "invalid_request_error",
|
|
"message": "Model does not support image inputs"
|
|
}
|
|
})
|
|
.to_string();
|
|
|
|
let wrapped_error = parse_wrapped_websocket_error_event(&payload)
|
|
.expect("expected websocket error payload to be parsed");
|
|
let api_error = map_wrapped_websocket_error_event(wrapped_error, payload)
|
|
.expect("expected websocket error payload to map to ApiError");
|
|
let ApiError::Transport(TransportError::Http { status, body, .. }) = api_error else {
|
|
panic!("expected ApiError::Transport(Http)");
|
|
};
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
let body = body.expect("expected body");
|
|
assert!(body.contains("invalid_request_error"));
|
|
assert!(body.contains("Model does not support image inputs"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_wrapped_websocket_error_event_with_connection_limit_maps_retryable() {
|
|
let payload = json!({
|
|
"type": "error",
|
|
"status": 400,
|
|
"error": {
|
|
"type": "invalid_request_error",
|
|
"code": "websocket_connection_limit_reached",
|
|
"message": "Responses websocket connection limit reached (60 minutes). Create a new websocket connection to continue."
|
|
}
|
|
})
|
|
.to_string();
|
|
|
|
let wrapped_error = parse_wrapped_websocket_error_event(&payload)
|
|
.expect("expected websocket error payload to be parsed");
|
|
let api_error = map_wrapped_websocket_error_event(wrapped_error, payload)
|
|
.expect("expected websocket error payload to map to ApiError");
|
|
let ApiError::Retryable { message, delay } = api_error else {
|
|
panic!("expected ApiError::Retryable");
|
|
};
|
|
assert_eq!(message, WEBSOCKET_CONNECTION_LIMIT_REACHED_MESSAGE);
|
|
assert_eq!(delay, None);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_wrapped_websocket_error_event_without_status_is_not_mapped() {
|
|
let payload = json!({
|
|
"type": "error",
|
|
"error": {
|
|
"type": "usage_limit_reached",
|
|
"message": "The usage limit has been reached"
|
|
},
|
|
"headers": {
|
|
"x-codex-primary-used-percent": "100.0",
|
|
"x-codex-primary-window-minutes": 15
|
|
}
|
|
})
|
|
.to_string();
|
|
|
|
let wrapped_error = parse_wrapped_websocket_error_event(&payload)
|
|
.expect("expected websocket error payload to be parsed");
|
|
let api_error = map_wrapped_websocket_error_event(wrapped_error, payload);
|
|
assert!(api_error.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn merge_request_headers_matches_http_precedence() {
|
|
let mut provider_headers = HeaderMap::new();
|
|
provider_headers.insert(
|
|
"originator",
|
|
HeaderValue::from_static("provider-originator"),
|
|
);
|
|
provider_headers.insert("x-priority", HeaderValue::from_static("provider"));
|
|
|
|
let mut extra_headers = HeaderMap::new();
|
|
extra_headers.insert("x-priority", HeaderValue::from_static("extra"));
|
|
|
|
let mut default_headers = HeaderMap::new();
|
|
default_headers.insert("originator", HeaderValue::from_static("default-originator"));
|
|
default_headers.insert("x-priority", HeaderValue::from_static("default"));
|
|
default_headers.insert("x-default-only", HeaderValue::from_static("default-only"));
|
|
|
|
let merged = merge_request_headers(&provider_headers, extra_headers, default_headers);
|
|
|
|
assert_eq!(
|
|
merged.get("originator"),
|
|
Some(&HeaderValue::from_static("provider-originator"))
|
|
);
|
|
assert_eq!(
|
|
merged.get("x-priority"),
|
|
Some(&HeaderValue::from_static("extra"))
|
|
);
|
|
assert_eq!(
|
|
merged.get("x-default-only"),
|
|
Some(&HeaderValue::from_static("default-only"))
|
|
);
|
|
}
|
|
}
|