Files
codex-proxy-rs/crates/adapters/tests/codex/codex_websocket.rs
T
2026-06-20 18:16:04 +08:00

1271 lines
47 KiB
Rust

use super::*;
#[tokio::test]
async fn websocket_audit_artifact_should_require_explicit_directory() {
let connection =
codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses(
"https://chatgpt.com",
"test-websocket-key",
vec![
(
"authorization".to_string(),
"Bearer access-token".to_string(),
),
("chatgpt-account-id".to_string(), "acct-secret".to_string()),
("cookie".to_string(), "session=secret".to_string()),
],
);
let mut payload_request =
codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"private instructions",
vec![json!({"role": "user", "content": "private prompt"})],
);
payload_request.prompt_cache_key = Some("cache-secret".to_string());
payload_request.client_metadata = Some(json!({"thread_id": "thread-secret"}));
let mut artifact =
codex_proxy_core::protocol::codex::websocket::websocket_audit_artifact_from_attempt(
&payload_request,
connection.opening_audit_snapshot(),
codex_proxy_core::protocol::codex::websocket::websocket_payload_audit_snapshot(
&payload_request,
),
);
artifact.error = Some(
codex_proxy_core::protocol::codex::websocket::WebSocketAuditErrorSnapshot {
classification: "opening_failed".to_string(),
message: "connection refused".to_string(),
},
);
let dir = tempfile::tempdir().expect("temp dir");
let disabled =
codex_proxy_adapters::codex::websocket::opening::write_websocket_audit_artifact_for_dir(
None, &artifact,
)
.await
.expect("disabled audit should be ok");
assert!(disabled.is_none());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
let written =
codex_proxy_adapters::codex::websocket::opening::write_websocket_audit_artifact_for_dir(
Some(dir.path()),
&artifact,
)
.await
.expect("enabled audit should write")
.expect("enabled audit path");
let body = std::fs::read_to_string(&written).expect("audit file");
let json = serde_json::from_str::<serde_json::Value>(&body).expect("audit json");
assert_eq!(json["transport_mode"], "websocket_preferred");
assert_eq!(
json["opening"]["request_line"],
"GET /codex/responses HTTP/1.1"
);
assert_eq!(json["payload"]["top_level_keys"][1], "model");
assert_eq!(json["error"]["classification"], "opening_failed");
assert!(!body.contains("access-token"));
assert!(!body.contains("acct-secret"));
assert!(!body.contains("session=secret"));
assert!(!body.contains("private prompt"));
assert!(!body.contains("private instructions"));
assert!(!body.contains("cache-secret"));
assert!(!body.contains("thread-secret"));
}
#[test]
fn websocket_responses_endpoint_should_convert_http_base_url_to_ws_endpoint() {
assert_eq!(
codex_proxy_adapters::codex::websocket::connect::responses_websocket_endpoint(
"https://chatgpt.com/backend-api"
),
"wss://chatgpt.com/backend-api/codex/responses"
);
assert_eq!(
codex_proxy_adapters::codex::websocket::connect::responses_websocket_endpoint(
"http://127.0.0.1:8080"
),
"ws://127.0.0.1:8080/codex/responses"
);
}
#[tokio::test]
async fn codex_backend_client_should_decode_permessage_deflate_context_takeover_frames() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket_with(stream, |request, response| {
let extensions = request
.headers()
.get("sec-websocket-extensions")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
assert!(extensions.contains("permessage-deflate"));
response.headers_mut().insert(
"sec-websocket-extensions",
"permessage-deflate".parse().unwrap(),
);
})
.await;
let delta = json!({
"type": "response.output_text.delta",
"delta": "hello from websocket"
})
.to_string();
let completed = json!({
"type": "response.completed",
"response": {
"id": "resp_6f8d0c2b5a4e4a0d9c1b7e3f2a8d5c6b",
"object": "response",
"usage": {
"input_tokens": 3,
"output_tokens": 1,
"total_tokens": 4
}
}
})
.to_string();
let Some(Ok(Message::Text(_payload))) = websocket.next().await else {
panic!("client should send response.create payload");
};
for payload in [delta, completed] {
websocket.send(Message::Text(payload.into())).await.unwrap();
}
});
let backend = client::CodexBackendClient::new(
client::build_reqwest_client(false).unwrap(),
format!("http://{addr}"),
codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(),
);
let mut request =
codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"be brief",
Vec::new(),
);
request.previous_response_id = Some("resp_previous".to_string());
let response = backend
.create_response(
&request,
request_context("req_live_deflate", Some("chatgpt-account")),
)
.await
.expect("deflated websocket response should decode");
server.await.unwrap();
assert!(response.body.contains("hello from websocket"));
assert!(response
.body
.contains("resp_6f8d0c2b5a4e4a0d9c1b7e3f2a8d5c6b"));
}
#[test]
fn websocket_connection_should_render_raw_opening_bytes_for_capture_parity() {
let connection =
codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses(
"https://chatgpt.com/backend-api",
"test-websocket-key",
vec![
(
"chatgpt-account-id".to_string(),
"chatgpt-account".to_string(),
),
(
"authorization".to_string(),
"Bearer access-token".to_string(),
),
(
"user-agent".to_string(),
"Codex Desktop/26.519.81530 (darwin; arm64)".to_string(),
),
("originator".to_string(), "Codex Desktop".to_string()),
(
"openai-beta".to_string(),
"responses_websockets=2026-02-06".to_string(),
),
(
"x-codex-beta-features".to_string(),
"terminal_resize_reflow,memories,network_proxy,prevent_idle_sleep,remote_compaction_v2".to_string(),
),
("x-client-request-id".to_string(), "session-1".to_string()),
("session-id".to_string(), "session-1".to_string()),
("thread-id".to_string(), "session-1".to_string()),
("x-codex-window-id".to_string(), "session-1:0".to_string()),
(
"x-codex-turn-metadata".to_string(),
r#"{"installation_id":"install-1","session_id":"session-1","thread_id":"session-1","turn_id":"","window_id":"session-1:0","request_kind":"prewarm","sandbox":"seccomp"}"#.to_string(),
),
],
);
let opening = connection.opening_request_text();
assert!(opening.starts_with("GET /backend-api/codex/responses HTTP/1.1\r\n"));
assert_substrings_appear_in_order(
&opening,
&[
"Host: chatgpt.com\r\n",
"Connection: Upgrade\r\n",
"Upgrade: websocket\r\n",
"Sec-WebSocket-Version: 13\r\n",
"Sec-WebSocket-Key: test-websocket-key\r\n",
"chatgpt-account-id: chatgpt-account\r\n",
"authorization: Bearer access-token\r\n",
"user-agent: Codex Desktop/26.519.81530 (darwin; arm64)\r\n",
"originator: Codex Desktop\r\n",
"openai-beta: responses_websockets=2026-02-06\r\n",
"x-codex-beta-features: terminal_resize_reflow,memories,network_proxy,prevent_idle_sleep,remote_compaction_v2\r\n",
"x-client-request-id: session-1\r\n",
"session-id: session-1\r\n",
"thread-id: session-1\r\n",
"x-codex-window-id: session-1:0\r\n",
r#"x-codex-turn-metadata: {"installation_id":"install-1","session_id":"session-1","thread_id":"session-1","turn_id":"","window_id":"session-1:0","request_kind":"prewarm","sandbox":"seccomp"}"#,
"sec-websocket-extensions: permessage-deflate; client_max_window_bits\r\n",
],
);
assert!(opening.ends_with("\r\n\r\n"));
}
#[test]
fn websocket_connection_should_prepare_response_create_payload_text() {
let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"be brief",
vec![json!({
"role": "user",
"content": "hello",
})],
);
let prepared =
codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request(
"https://chatgpt.com/backend-api",
"test-websocket-key",
vec![("authorization".to_string(), "Bearer access-token".to_string())],
&request,
)
.expect("payload should serialize");
let payload: serde_json::Value =
serde_json::from_str(prepared.payload_text()).expect("payload should be json");
assert_eq!(
prepared.connection().endpoint(),
"wss://chatgpt.com/backend-api/codex/responses"
);
assert_eq!(payload["type"], "response.create");
assert_eq!(payload["model"], "gpt-5.5");
assert_eq!(payload["instructions"], "be brief");
assert_eq!(payload["input"][0]["content"], "hello");
assert_eq!(payload["stream"], true);
}
#[test]
fn websocket_connection_should_prepare_capture_payload_with_canonical_field_order() {
let mut request =
codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"private capture instructions",
vec![json!({
"role": "user",
"content": "private capture prompt",
})],
);
request.prompt_cache_key = Some("session-1".to_string());
request.client_metadata = Some(json!({
"thread_id": "capture-thread-secret",
"safe": "capture",
}));
let prepared =
codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request(
"https://chatgpt.com/backend-api",
"test-websocket-key",
vec![("authorization".to_string(), "Bearer access-token".to_string())],
&request,
)
.expect("payload should serialize");
assert_substrings_appear_in_order(
prepared.payload_text(),
&[
"\"type\":\"response.create\"",
"\"model\":\"gpt-5.5\"",
"\"instructions\":\"private capture instructions\"",
"\"input\":",
"\"store\":false",
"\"stream\":true",
"\"tool_choice\":\"auto\"",
"\"parallel_tool_calls\":true",
"\"prompt_cache_key\":\"session-1\"",
"\"client_metadata\":",
],
);
}
#[tokio::test]
async fn websocket_execute_response_create_request_should_collect_completed_sse() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket(stream).await;
let message = websocket.next().await.unwrap().unwrap();
let payload = serde_json::from_str::<serde_json::Value>(&message.into_text().unwrap())
.expect("client payload should be json");
websocket
.send(Message::Text(
json!({
"type": "response.completed",
"response": {
"id": "resp_ws_live",
"object": "response",
"usage": {
"input_tokens": 5,
"output_tokens": 2,
"total_tokens": 7
}
}
})
.to_string()
.into(),
))
.await
.unwrap();
websocket.close(None).await.unwrap();
payload
});
let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"be brief",
Vec::new(),
);
let prepared =
codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request(
&format!("http://{addr}"),
"dGhlIHNhbXBsZSBub25jZQ==",
vec![("authorization".to_string(), "Bearer access-token".to_string())],
&request,
)
.expect("payload should serialize");
let response =
codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared)
.await
.expect("websocket exchange should succeed");
let payload = server.await.unwrap();
assert_eq!(payload["type"], "response.create");
assert!(response.body.contains("event: response.completed"));
assert!(response.body.contains("\"id\":\"resp_ws_live\""));
assert_eq!(response.usage.expect("usage").input_tokens, 5);
}
#[tokio::test]
async fn websocket_execute_response_create_request_should_surface_response_failed_as_upstream_error(
) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket(stream).await;
let _message = websocket.next().await.unwrap().unwrap();
websocket
.send(Message::Text(
json!({
"type": "response.failed",
"response": {
"error": {
"code": "rate_limit_exceeded",
"message": "Rate limit reached. Please try again in 11.054s."
}
}
})
.to_string()
.into(),
))
.await
.unwrap();
websocket.close(None).await.unwrap();
});
let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"be brief",
Vec::new(),
);
let prepared =
codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request(
&format!("http://{addr}"),
"dGhlIHNhbXBsZSBub25jZQ==",
vec![("authorization".to_string(), "Bearer access-token".to_string())],
&request,
)
.expect("payload should serialize");
let error =
codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared)
.await
.expect_err("response.failed should be surfaced as upstream error");
server.await.unwrap();
let codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::Upstream {
status_code,
retry_after_seconds,
body,
..
} = error
else {
panic!("expected upstream websocket error");
};
assert_eq!(status_code, 429);
assert_eq!(retry_after_seconds, Some(12));
assert!(body.contains("rate_limit_exceeded"));
}
#[tokio::test]
async fn websocket_execute_response_create_request_should_preserve_opening_error_status_body_and_retry_after(
) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut stream).await;
assert!(request.starts_with("GET /codex/responses HTTP/1.1"));
assert!(request.contains("authorization: Bearer access-token"));
let body = r#"{"error":{"message":"rate limited"}}"#;
stream
.write_all(
format!(
"HTTP/1.1 429 Too Many Requests\r\nretry-after: 33\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
)
.as_bytes(),
)
.await
.unwrap();
});
let prepared = prepared_websocket_request(&format!("http://{addr}"));
let error =
codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared)
.await
.expect_err("failed opening should surface upstream status");
server.await.unwrap();
let codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::Upstream {
status_code,
retry_after_seconds,
body,
..
} = error
else {
panic!("expected upstream opening error");
};
assert_eq!(status_code, 429);
assert_eq!(retry_after_seconds, Some(33));
assert!(body.contains("rate limited"));
}
#[tokio::test]
async fn websocket_execute_response_create_request_should_reject_binary_event() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket(stream).await;
let _message = websocket.next().await.unwrap().unwrap();
websocket
.send(Message::Binary(b"unexpected-binary".to_vec().into()))
.await
.unwrap();
websocket.close(None).await.unwrap();
});
let prepared = prepared_websocket_request(&format!("http://{addr}"));
let error =
codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared)
.await
.expect_err("binary websocket events should be rejected");
server.await.unwrap();
assert!(matches!(
error,
codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::UnexpectedBinaryEvent
));
}
#[tokio::test]
async fn websocket_execute_response_create_request_should_surface_wrapped_error_status_and_retry_after(
) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket(stream).await;
let _message = websocket.next().await.unwrap().unwrap();
websocket
.send(Message::Text(
json!({
"type": "error",
"status": 409,
"headers": {
"retry-after": ["17"]
},
"error": {
"code": "conflict",
"message": "wrapped conflict"
}
})
.to_string()
.into(),
))
.await
.unwrap();
websocket.close(None).await.unwrap();
});
let prepared = prepared_websocket_request(&format!("http://{addr}"));
let error =
codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared)
.await
.expect_err("wrapped error should surface as upstream error");
server.await.unwrap();
let codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::Upstream {
status_code,
retry_after_seconds,
body,
..
} = error
else {
panic!("expected wrapped upstream error");
};
assert_eq!(status_code, 409);
assert_eq!(retry_after_seconds, Some(17));
assert!(body.contains("wrapped conflict"));
}
#[tokio::test]
async fn websocket_execute_response_create_request_should_surface_connection_limit_as_503() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket(stream).await;
let _message = websocket.next().await.unwrap().unwrap();
websocket
.send(Message::Text(
json!({
"type": "response.failed",
"response": {
"id": "resp_connection_limit",
"error": {
"code": "websocket_connection_limit_reached",
"message": "connection limit reached"
}
}
})
.to_string()
.into(),
))
.await
.unwrap();
websocket.close(None).await.unwrap();
});
let prepared = prepared_websocket_request(&format!("http://{addr}"));
let error =
codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared)
.await
.expect_err("connection limit should surface as upstream error");
server.await.unwrap();
let codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::Upstream {
status_code,
body,
..
} = error
else {
panic!("expected connection limit upstream error");
};
assert_eq!(status_code, 503);
assert!(body.contains("websocket_connection_limit_reached"));
}
#[tokio::test]
async fn websocket_execute_response_create_request_should_skip_invalid_stream_events() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket(stream).await;
let _message = websocket.next().await.unwrap().unwrap();
websocket
.send(Message::Text(
json!({
"type": "response.output_text.delta"
})
.to_string()
.into(),
))
.await
.unwrap();
websocket
.send(Message::Text(
json!({
"type": "response.completed",
"response": {
"id": "resp_after_invalid",
"object": "response"
}
})
.to_string()
.into(),
))
.await
.unwrap();
websocket.close(None).await.unwrap();
});
let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"be brief",
Vec::new(),
);
let prepared =
codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request(
&format!("http://{addr}"),
"dGhlIHNhbXBsZSBub25jZQ==",
vec![("authorization".to_string(), "Bearer access-token".to_string())],
&request,
)
.expect("payload should serialize");
let response =
codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared)
.await
.expect("websocket exchange should succeed");
server.await.unwrap();
assert!(!response.body.contains("response.output_text.delta"));
assert!(response.body.contains("event: response.completed"));
assert!(response.body.contains("\"id\":\"resp_after_invalid\""));
}
#[tokio::test]
async fn websocket_execute_response_create_request_should_capture_internal_metadata_and_rate_limit_events(
) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket(stream).await;
let _message = websocket.next().await.unwrap().unwrap();
websocket
.send(Message::Text(
json!({
"type": "codex.rate_limits",
"rate_limits": {
"primary": {
"used_percent": 100,
"window_minutes": 5,
"reset_at": 1893456300
}
}
})
.to_string()
.into(),
))
.await
.unwrap();
websocket
.send(Message::Text(
json!({
"type": "response.metadata",
"headers": {
"x-codex-turn-state": ["turn-from-metadata"]
}
})
.to_string()
.into(),
))
.await
.unwrap();
websocket
.send(Message::Text(
json!({
"type": "response.completed",
"response": {
"id": "resp_internal_events",
"object": "response",
"usage": {
"input_tokens": 1,
"output_tokens": 1,
"total_tokens": 2
}
}
})
.to_string()
.into(),
))
.await
.unwrap();
websocket.close(None).await.unwrap();
});
let prepared = prepared_websocket_request(&format!("http://{addr}"));
let response =
codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared)
.await
.expect("internal events should update metadata without forwarding");
server.await.unwrap();
assert_eq!(response.turn_state.as_deref(), Some("turn-from-metadata"));
assert!(!response.body.contains("codex.rate_limits"));
assert!(!response.body.contains("response.metadata"));
assert!(response.body.contains("event: response.completed"));
assert!(response
.rate_limit_headers
.iter()
.any(|(name, value)| name == "x-codex-primary-used-percent" && value == "100"));
assert!(response
.rate_limit_headers
.iter()
.any(|(name, value)| name == "x-codex-primary-reset-at" && value == "1893456300"));
}
#[tokio::test]
async fn websocket_execute_response_create_request_should_surface_incomplete_response() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket(stream).await;
let _message = websocket.next().await.unwrap().unwrap();
websocket
.send(Message::Text(
json!({
"type": "response.incomplete",
"response": {
"id": "resp_incomplete",
"incomplete_details": {
"reason": "max_output_tokens"
}
}
})
.to_string()
.into(),
))
.await
.unwrap();
websocket.close(None).await.unwrap();
});
let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"be brief",
Vec::new(),
);
let prepared =
codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request(
&format!("http://{addr}"),
"dGhlIHNhbXBsZSBub25jZQ==",
vec![("authorization".to_string(), "Bearer access-token".to_string())],
&request,
)
.expect("payload should serialize");
let error =
codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared)
.await
.expect_err("response.incomplete should be surfaced as websocket error");
server.await.unwrap();
let codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::IncompleteResponse {
reason,
} = error
else {
panic!("expected incomplete websocket response");
};
assert_eq!(reason, "max_output_tokens");
}
#[tokio::test]
async fn websocket_execute_response_create_request_should_reject_invalid_completed_response() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket(stream).await;
let _message = websocket.next().await.unwrap().unwrap();
websocket
.send(Message::Text(
json!({
"type": "response.completed",
"response": {
"id": "resp_invalid_completed",
"object": "response",
"usage": {
"input_tokens": "bad",
"output_tokens": 1,
"total_tokens": 1
}
}
})
.to_string()
.into(),
))
.await
.unwrap();
websocket.close(None).await.unwrap();
});
let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"be brief",
Vec::new(),
);
let prepared =
codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request(
&format!("http://{addr}"),
"dGhlIHNhbXBsZSBub25jZQ==",
vec![("authorization".to_string(), "Bearer access-token".to_string())],
&request,
)
.expect("payload should serialize");
let error =
codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared)
.await
.expect_err("invalid response.completed should be rejected");
server.await.unwrap();
let codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::InvalidCompletedResponse {
message,
} = error
else {
panic!("expected invalid completed websocket response");
};
assert!(message.contains("failed to parse ResponseCompleted"));
}
#[tokio::test]
async fn websocket_execute_response_create_request_should_ignore_completed_without_response_until_close(
) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket(stream).await;
let _message = websocket.next().await.unwrap().unwrap();
websocket
.send(Message::Text(
json!({
"type": "response.completed"
})
.to_string()
.into(),
))
.await
.unwrap();
websocket.close(None).await.unwrap();
});
let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"be brief",
Vec::new(),
);
let prepared =
codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request(
&format!("http://{addr}"),
"dGhlIHNhbXBsZSBub25jZQ==",
vec![("authorization".to_string(), "Bearer access-token".to_string())],
&request,
)
.expect("payload should serialize");
let error =
codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared)
.await
.expect_err("response.completed without response should not finish the stream");
server.await.unwrap();
assert!(matches!(
error,
codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::ClosedBeforeTerminal
));
}
#[tokio::test]
async fn websocket_execute_response_create_request_should_ignore_success_status_error_until_close()
{
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket(stream).await;
let _message = websocket.next().await.unwrap().unwrap();
websocket
.send(Message::Text(
json!({
"type": "error",
"status": 200,
"error": {
"message": "non-terminal informational frame"
}
})
.to_string()
.into(),
))
.await
.unwrap();
websocket.close(None).await.unwrap();
});
let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"be brief",
Vec::new(),
);
let prepared =
codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request(
&format!("http://{addr}"),
"dGhlIHNhbXBsZSBub25jZQ==",
vec![("authorization".to_string(), "Bearer access-token".to_string())],
&request,
)
.expect("payload should serialize");
let error =
codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared)
.await
.expect_err("unclassified success-status error frame should not finish the stream");
server.await.unwrap();
assert!(matches!(
error,
codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::ClosedBeforeTerminal
));
}
#[tokio::test(start_paused = true)]
async fn websocket_execute_response_create_request_should_timeout_when_upstream_is_silent() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (request_seen_tx, request_seen_rx) = tokio::sync::oneshot::channel();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket(stream).await;
let _message = websocket.next().await.unwrap().unwrap();
request_seen_tx.send(()).unwrap();
futures::future::pending::<()>().await;
});
let prepared = prepared_websocket_request(&format!("http://{addr}"));
let response_task = tokio::spawn(async move {
codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared)
.await
});
request_seen_rx.await.unwrap();
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(20)).await;
tokio::task::yield_now().await;
let error = response_task
.await
.expect("websocket task should finish")
.expect_err("silent upstream should time out");
server.abort();
let codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::ReceiveIdleTimeout {
timeout,
} = error
else {
panic!("expected receive idle timeout");
};
assert_eq!(timeout, Duration::from_secs(20));
}
#[tokio::test]
async fn websocket_execute_response_create_request_should_reply_to_server_ping_before_terminal() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket(stream).await;
let _message = websocket.next().await.unwrap().unwrap();
websocket
.send(Message::Ping("codex-ping".as_bytes().to_vec().into()))
.await
.unwrap();
let pong = timeout(Duration::from_secs(1), websocket.next())
.await
.expect("client should reply to server ping before terminal response")
.expect("client should send a websocket frame")
.expect("client frame should be valid");
assert_eq!(pong, Message::Pong("codex-ping".as_bytes().to_vec().into()));
websocket
.send(Message::Text(
json!({
"type": "response.completed",
"response": {
"id": "resp_after_ping",
"object": "response",
"usage": {
"input_tokens": 2,
"output_tokens": 1,
"total_tokens": 3
}
}
})
.to_string()
.into(),
))
.await
.unwrap();
websocket.close(None).await.unwrap();
});
let request = codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"be brief",
Vec::new(),
);
let prepared =
codex_proxy_adapters::codex::websocket::connect::CodexWebSocketConnection::responses_create_request(
&format!("http://{addr}"),
"dGhlIHNhbXBsZSBub25jZQ==",
vec![("authorization".to_string(), "Bearer access-token".to_string())],
&request,
)
.expect("payload should serialize");
let response =
codex_proxy_adapters::codex::websocket::connect::execute_response_create_request(&prepared)
.await
.expect("websocket exchange should succeed after ping/pong");
server.await.unwrap();
assert!(response.body.contains("event: response.completed"));
assert!(response.body.contains("\"id\":\"resp_after_ping\""));
assert_eq!(response.usage.expect("usage").input_tokens, 2);
}
#[tokio::test]
async fn codex_backend_client_stream_should_reject_binary_websocket_event() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket(stream).await;
let _message = websocket.next().await.unwrap().unwrap();
websocket
.send(Message::Binary(b"unexpected-binary".to_vec().into()))
.await
.unwrap();
websocket.close(None).await.unwrap();
});
let mut request =
codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"be brief",
Vec::new(),
);
request.previous_response_id = Some("resp_previous".to_string());
let backend = client::CodexBackendClient::new(
client::build_reqwest_client(false).unwrap(),
format!("http://{addr}"),
codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(),
);
let mut response = backend
.create_response_stream(
&request,
request_context("req_stream_binary", Some("chatgpt-account")),
)
.await
.expect("websocket stream should open");
let error = response
.body
.next()
.await
.expect("stream should yield binary error")
.expect_err("binary event should be rejected");
server.await.unwrap();
assert!(matches!(
error,
client::CodexClientError::WebSocket(
codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::UnexpectedBinaryEvent
)
));
}
#[tokio::test]
async fn codex_backend_client_stream_should_error_when_websocket_closes_before_terminal() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket(stream).await;
let _message = websocket.next().await.unwrap().unwrap();
websocket
.send(Message::Text(
json!({
"type": "response.output_text.delta",
"delta": "partial"
})
.to_string()
.into(),
))
.await
.unwrap();
websocket.close(None).await.unwrap();
});
let mut request =
codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"be brief",
Vec::new(),
);
request.previous_response_id = Some("resp_previous".to_string());
let backend = client::CodexBackendClient::new(
client::build_reqwest_client(false).unwrap(),
format!("http://{addr}"),
codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(),
);
let mut response = backend
.create_response_stream(
&request,
request_context("req_stream_mid_close", Some("chatgpt-account")),
)
.await
.expect("websocket stream should open");
let first = response
.body
.next()
.await
.expect("stream should yield partial frame")
.expect("partial frame should be valid");
assert!(std::str::from_utf8(&first).unwrap().contains("partial"));
let error = response
.body
.next()
.await
.expect("stream should yield close-before-terminal error")
.expect_err("close before terminal should be an error");
server.await.unwrap();
assert!(matches!(
error,
client::CodexClientError::WebSocket(
codex_proxy_adapters::codex::websocket::connect::CodexWebSocketExchangeError::ClosedBeforeTerminal
)
));
}
#[tokio::test]
async fn codex_backend_client_should_use_websocket_when_previous_response_id_is_present() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket_with(stream, |_request, response| {
response.headers_mut().insert(
"sec-websocket-extensions",
"permessage-deflate".parse().unwrap(),
);
response
.headers_mut()
.insert("x-codex-turn-state", "turn-ws-client".parse().unwrap());
response.headers_mut().insert(
"set-cookie",
"cf_clearance=client-ws; Domain=.chatgpt.com; Path=/"
.parse()
.unwrap(),
);
response
.headers_mut()
.insert("x-ratelimit-remaining-requests", "17".parse().unwrap());
})
.await;
let message = websocket.next().await.unwrap().unwrap();
let payload = serde_json::from_str::<serde_json::Value>(&message.into_text().unwrap())
.expect("client payload should be json");
websocket
.send(Message::Text(
json!({
"type": "response.completed",
"response": {
"id": "resp_ws_client",
"object": "response",
"usage": {
"input_tokens": 3,
"output_tokens": 1,
"total_tokens": 4
}
}
})
.to_string()
.into(),
))
.await
.unwrap();
websocket.close(None).await.unwrap();
payload
});
let mut request =
codex_proxy_core::protocol::codex::responses::CodexResponsesRequest::new_http_sse(
"gpt-5.5",
"be brief",
Vec::new(),
);
request.previous_response_id = Some("resp_previous".to_string());
let backend = client::CodexBackendClient::new(
client::build_reqwest_client(false).unwrap(),
format!("http://{addr}"),
codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests(),
);
let response = backend
.create_response(
&request,
client::CodexRequestContext {
access_token: "access-token",
account_id: Some("chatgpt-account"),
request_id: "req_ws_client",
turn_state: None,
turn_metadata: None,
beta_features: None,
include_timing_metrics: None,
version: None,
codex_window_id: None,
parent_thread_id: None,
cookie_header: None,
installation_id: None,
session_id: None,
},
)
.await
.expect("websocket response should succeed");
let payload = server.await.unwrap();
assert_eq!(payload["type"], "response.create");
assert_eq!(payload["previous_response_id"], "resp_previous");
assert!(response.body.contains("event: response.completed"));
assert!(response.body.contains("\"id\":\"resp_ws_client\""));
assert_eq!(response.usage.expect("usage").input_tokens, 3);
assert_eq!(response.turn_state.as_deref(), Some("turn-ws-client"));
assert_eq!(
response.set_cookie_headers,
vec!["cf_clearance=client-ws; Domain=.chatgpt.com; Path=/".to_string()]
);
assert!(response
.rate_limit_headers
.iter()
.any(|(name, value)| name == "x-ratelimit-remaining-requests" && value == "17"));
}