mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
Preserve Responses Lite and Memgen controls across HTTP and WebSocket transports without rewriting request bodies, and standardize continuation recovery signals. Track cache-write usage and billing while applying appcast profile updates to new WebSocket generations without breaking exact continuations. Co-authored-by: Codex <noreply@openai.com>
1155 lines
44 KiB
Rust
1155 lines
44 KiB
Rust
use std::{fs, net::SocketAddr, path::Path, time::Duration};
|
|
|
|
use axum::Router;
|
|
use chrono::Utc;
|
|
use codex_proxy_rs::fleet::{account::AccountStatus, cookies::PgCookieStore};
|
|
use futures::{SinkExt, StreamExt};
|
|
use serde_json::{Value, json};
|
|
use tokio::{net::TcpListener, task::JoinHandle, time::timeout};
|
|
use tokio_tungstenite::{
|
|
WebSocketStream, connect_async,
|
|
tungstenite::{
|
|
Error as WebSocketError, Message,
|
|
client::IntoClientRequest,
|
|
http::{HeaderValue, header::AUTHORIZATION},
|
|
},
|
|
};
|
|
|
|
use crate::dispatch::service::{
|
|
accept_async as accept_upstream_websocket, accept_websocket_with_authorization,
|
|
accept_websocket_with_request_headers,
|
|
};
|
|
|
|
struct TestServer {
|
|
address: SocketAddr,
|
|
task: JoinHandle<()>,
|
|
}
|
|
|
|
impl Drop for TestServer {
|
|
fn drop(&mut self) {
|
|
self.task.abort();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn responses_websocket_replay_policy_should_be_owned_by_history_controller() {
|
|
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
let api = fs::read_to_string(manifest.join("src/api/client/responses/websocket.rs")).unwrap();
|
|
let service = fs::read_to_string(manifest.join("src/dispatch/service.rs")).unwrap();
|
|
let history = fs::read_to_string(manifest.join("src/dispatch/controllers/history.rs")).unwrap();
|
|
let protocol =
|
|
fs::read_to_string(manifest.join("src/upstream/openai/protocol/responses.rs")).unwrap();
|
|
|
|
assert!(api.contains(".prepare_connection_replay("));
|
|
assert!(api.contains(".commit_connection_replay("));
|
|
for forbidden in [
|
|
"ConnectionReplayState",
|
|
"PendingReplayUpdate",
|
|
"sanitize_cross_account_item",
|
|
".previous_response_id()",
|
|
"dispatch::controllers",
|
|
] {
|
|
assert!(
|
|
!api.contains(forbidden),
|
|
"Responses WebSocket API must not own replay policy {forbidden}"
|
|
);
|
|
}
|
|
for owned in [
|
|
"enum ConnectionReplayUpdate",
|
|
"ConnectionReplayUpdate::Replace",
|
|
"ConnectionReplayUpdate::Append",
|
|
"ConnectionReplayUpdate::Unavailable",
|
|
"struct CrossAccountReplay",
|
|
"LocalReplayItem::ClientInput",
|
|
"sanitize_cross_account_item",
|
|
"project_transcript_to_account",
|
|
"snapshot.last_response_id.as_deref() == Some(previous_response_id)",
|
|
] {
|
|
assert!(
|
|
history.contains(owned),
|
|
"HistoryController owner must contain {owned}"
|
|
);
|
|
}
|
|
for boundary in [
|
|
"pub(crate) struct ConnectionReplaySnapshot",
|
|
"pub(crate) struct ConnectionReplayPlan",
|
|
"pub(crate) struct ConnectionTranscriptFacts",
|
|
"fn prepare_connection_replay(",
|
|
"fn commit_connection_replay(",
|
|
] {
|
|
assert!(
|
|
service.contains(boundary),
|
|
"ResponseDispatchService must expose narrow replay boundary {boundary}"
|
|
);
|
|
}
|
|
assert!(protocol.contains("enum LocalReplayItem"));
|
|
assert!(!protocol.contains("sanitize_cross_account_item"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn responses_websocket_should_reject_missing_client_api_key_during_handshake() {
|
|
let (app, _api_key, _dir, _connections) = super::test_app_with_client_api_key().await;
|
|
let server = spawn_app(app).await;
|
|
let request = format!("ws://{}/v1/responses", server.address)
|
|
.into_client_request()
|
|
.unwrap();
|
|
|
|
let error = connect_async(request).await.unwrap_err();
|
|
|
|
let WebSocketError::Http(response) = error else {
|
|
panic!("expected HTTP handshake rejection");
|
|
};
|
|
assert_eq!(response.status(), 401);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn responses_websocket_should_return_official_error_frame_when_accounts_are_unavailable() {
|
|
let (app, api_key, _dir, _connections) = super::test_app_with_client_api_key().await;
|
|
let server = spawn_app(app).await;
|
|
let mut websocket = connect_responses_websocket(server.address, &api_key).await;
|
|
|
|
websocket
|
|
.send(Message::Text(response_create_payload("no accounts").into()))
|
|
.await
|
|
.unwrap();
|
|
let events = receive_response(&mut websocket).await;
|
|
|
|
assert_eq!(events.last().unwrap()["type"], "error");
|
|
assert_eq!(events.last().unwrap()["status"], 503);
|
|
assert_eq!(
|
|
events.last().unwrap()["error"]["code"],
|
|
"no_available_accounts"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn responses_websocket_should_preserve_upstream_client_failure() {
|
|
let upstream_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let upstream_base_url = format!("http://{}", upstream_listener.local_addr().unwrap());
|
|
let upstream = tokio::spawn(async move {
|
|
let (stream, _) = upstream_listener.accept().await.unwrap();
|
|
let mut websocket = accept_upstream_websocket(stream).await.unwrap();
|
|
let _request = receive_upstream_request(&mut websocket).await;
|
|
websocket
|
|
.send(Message::Text(
|
|
json!({
|
|
"type": "response.failed",
|
|
"response": {
|
|
"id": "resp_server_overloaded",
|
|
"status": "failed",
|
|
"error": {
|
|
"code": "server_overloaded",
|
|
"message": "upstream is temporarily overloaded",
|
|
},
|
|
},
|
|
})
|
|
.to_string()
|
|
.into(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
websocket.close(None).await.unwrap();
|
|
});
|
|
let (app, api_key, _dir) =
|
|
crate::dispatch::service::test_app_with_account(upstream_base_url).await;
|
|
let server = spawn_app(app).await;
|
|
let mut websocket = connect_responses_websocket(server.address, &api_key).await;
|
|
|
|
websocket
|
|
.send(Message::Text(response_create_payload("overload").into()))
|
|
.await
|
|
.unwrap();
|
|
let events = receive_response(&mut websocket).await;
|
|
upstream.await.unwrap();
|
|
let failure = events.last().unwrap();
|
|
|
|
assert_eq!(failure["type"], "error");
|
|
assert_eq!(failure["status"], 503);
|
|
assert_eq!(failure["error"]["code"], "server_overloaded");
|
|
assert_eq!(
|
|
failure["error"]["message"],
|
|
"upstream is temporarily overloaded"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn responses_websocket_should_disconnect_when_connection_draining_starts() {
|
|
let (app, api_key, _dir, connection_drain) = super::test_app_with_client_api_key().await;
|
|
let server = spawn_app(app).await;
|
|
let mut websocket = connect_responses_websocket(server.address, &api_key).await;
|
|
|
|
assert_eq!(connection_drain.begin_shutdown(), 1);
|
|
tokio::time::timeout(Duration::from_secs(1), connection_drain.wait())
|
|
.await
|
|
.expect("Responses WebSocket task should stop during connection draining");
|
|
let disconnected = tokio::time::timeout(Duration::from_secs(1), websocket.next())
|
|
.await
|
|
.expect("Responses WebSocket client should observe the disconnected connection");
|
|
assert!(matches!(
|
|
disconnected,
|
|
None | Some(Err(_)) | Some(Ok(Message::Close(_)))
|
|
));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn responses_websocket_should_forward_multiple_response_create_requests_on_one_connection() {
|
|
let upstream_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let upstream_base_url = format!("http://{}", upstream_listener.local_addr().unwrap());
|
|
let upstream = tokio::spawn(async move {
|
|
let (stream, _) = upstream_listener.accept().await.unwrap();
|
|
let mut websocket = accept_upstream_websocket(stream).await.unwrap();
|
|
let mut requests = Vec::new();
|
|
for index in 1..=2 {
|
|
let request = websocket.next().await.unwrap().unwrap();
|
|
requests.push(serde_json::from_str::<Value>(&request.into_text().unwrap()).unwrap());
|
|
send_upstream_response(&mut websocket, &format!("resp_{index}"), "ok").await;
|
|
}
|
|
requests
|
|
});
|
|
let (app, api_key, _dir) =
|
|
crate::dispatch::service::test_app_with_account(upstream_base_url).await;
|
|
let server = spawn_app(app).await;
|
|
let mut websocket = connect_responses_websocket(server.address, &api_key).await;
|
|
|
|
websocket
|
|
.send(Message::Text(response_create_payload("first").into()))
|
|
.await
|
|
.unwrap();
|
|
let first = receive_response(&mut websocket).await;
|
|
websocket
|
|
.send(Message::Text(response_create_payload("second").into()))
|
|
.await
|
|
.unwrap();
|
|
let second = receive_response(&mut websocket).await;
|
|
let upstream_requests = upstream.await.unwrap();
|
|
|
|
assert_eq!(response_event_types(&first), response_event_types(&second));
|
|
assert_eq!(first.first().unwrap()["type"], "response.metadata");
|
|
assert_eq!(first.last().unwrap()["type"], "response.completed");
|
|
assert_eq!(upstream_requests.len(), 2);
|
|
assert_eq!(upstream_requests[0]["type"], "response.create");
|
|
assert_eq!(upstream_requests[1]["type"], "response.create");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn responses_websocket_should_accept_omitted_stream_without_injecting_it() {
|
|
let upstream_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let upstream_base_url = format!("http://{}", upstream_listener.local_addr().unwrap());
|
|
let upstream = tokio::spawn(async move {
|
|
let (stream, _) = upstream_listener.accept().await.unwrap();
|
|
let mut websocket = accept_upstream_websocket(stream).await.unwrap();
|
|
let request = websocket.next().await.unwrap().unwrap();
|
|
let request = serde_json::from_str::<Value>(&request.into_text().unwrap()).unwrap();
|
|
send_upstream_response(&mut websocket, "resp_omitted_stream", "ok").await;
|
|
request
|
|
});
|
|
let (app, api_key, _dir) =
|
|
crate::dispatch::service::test_app_with_account(upstream_base_url).await;
|
|
let server = spawn_app(app).await;
|
|
let mut websocket = connect_responses_websocket(server.address, &api_key).await;
|
|
let mut payload: Value = serde_json::from_str(&response_create_payload("hello")).unwrap();
|
|
payload.as_object_mut().unwrap().remove("stream");
|
|
|
|
websocket
|
|
.send(Message::Text(payload.to_string().into()))
|
|
.await
|
|
.unwrap();
|
|
let response = receive_response(&mut websocket).await;
|
|
let upstream_request = upstream.await.unwrap();
|
|
|
|
assert_eq!(response.last().unwrap()["type"], "response.completed");
|
|
assert!(upstream_request.get("stream").is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn responses_websocket_cyber_terminal_should_change_the_immediate_next_request() {
|
|
let upstream_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let upstream_base_url = format!("http://{}", upstream_listener.local_addr().unwrap());
|
|
let upstream = tokio::spawn(async move {
|
|
let (primary_stream, _) = upstream_listener.accept().await.unwrap();
|
|
let mut primary =
|
|
accept_websocket_with_authorization(primary_stream, "Bearer access-primary").await;
|
|
let primary_request = receive_upstream_request(&mut primary).await;
|
|
for event in [
|
|
json!({
|
|
"type": "response.created",
|
|
"response": {"id": "resp_downstream_ws_cyber", "status": "in_progress"},
|
|
}),
|
|
json!({
|
|
"type": "response.output_text.delta",
|
|
"delta": "partial downstream WebSocket output",
|
|
}),
|
|
json!({
|
|
"type": "response.failed",
|
|
"response": {
|
|
"id": "resp_downstream_ws_cyber",
|
|
"status": "failed",
|
|
"error": {
|
|
"code": "cyber_policy",
|
|
"message": "This request has been flagged for possible cybersecurity risk.",
|
|
},
|
|
},
|
|
}),
|
|
] {
|
|
primary
|
|
.send(Message::Text(event.to_string().into()))
|
|
.await
|
|
.unwrap();
|
|
}
|
|
primary.close(None).await.unwrap();
|
|
|
|
let (secondary_stream, _) = upstream_listener.accept().await.unwrap();
|
|
let mut secondary =
|
|
accept_websocket_with_authorization(secondary_stream, "Bearer access-secondary").await;
|
|
let secondary_request = receive_upstream_request(&mut secondary).await;
|
|
send_upstream_response(&mut secondary, "resp_downstream_ws_rotated", "rotated").await;
|
|
(primary_request, secondary_request)
|
|
});
|
|
let (app, state, api_key, _pool, _dir) =
|
|
crate::dispatch::service::test_app_with_two_accounts_and_state(upstream_base_url).await;
|
|
assert!(
|
|
state
|
|
.services
|
|
.account_pool
|
|
.set_status("acct_secondary", AccountStatus::Disabled)
|
|
.await
|
|
);
|
|
let server = spawn_app(app).await;
|
|
let mut websocket = connect_responses_websocket(server.address, &api_key).await;
|
|
|
|
websocket
|
|
.send(Message::Text(response_create_payload("first").into()))
|
|
.await
|
|
.unwrap();
|
|
let first = receive_response(&mut websocket).await;
|
|
assert_eq!(first.last().unwrap()["type"], "response.failed");
|
|
|
|
assert!(
|
|
state
|
|
.services
|
|
.account_pool
|
|
.set_status("acct_secondary", AccountStatus::Active)
|
|
.await
|
|
);
|
|
websocket
|
|
.send(Message::Text(response_create_payload("second").into()))
|
|
.await
|
|
.unwrap();
|
|
let second = receive_response(&mut websocket).await;
|
|
let (primary_request, secondary_request) = upstream.await.unwrap();
|
|
|
|
assert_eq!(
|
|
second.last().unwrap()["response"]["id"],
|
|
"resp_downstream_ws_rotated"
|
|
);
|
|
assert_eq!(primary_request["input"][0]["content"], "first");
|
|
assert_eq!(secondary_request["input"][0]["content"], "second");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn responses_websocket_should_replay_connection_history_after_previous_response_not_found() {
|
|
let upstream_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let upstream_base_url = format!("http://{}", upstream_listener.local_addr().unwrap());
|
|
let upstream = tokio::spawn(async move {
|
|
let (first_stream, _) = upstream_listener.accept().await.unwrap();
|
|
let mut first = accept_upstream_websocket(first_stream).await.unwrap();
|
|
let initial = receive_upstream_request(&mut first).await;
|
|
send_upstream_response(&mut first, "resp_first", "first answer").await;
|
|
|
|
let continued = receive_upstream_request(&mut first).await;
|
|
first
|
|
.send(Message::Text(
|
|
json!({
|
|
"type": "response.failed",
|
|
"response": {
|
|
"id": "resp_missing",
|
|
"status": "failed",
|
|
"error": {
|
|
"code": "previous_response_not_found",
|
|
"message": "Previous response with id resp_first was not found",
|
|
},
|
|
},
|
|
})
|
|
.to_string()
|
|
.into(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
first.close(None).await.unwrap();
|
|
|
|
let (replay_stream, _) = upstream_listener.accept().await.unwrap();
|
|
let mut replay = accept_upstream_websocket(replay_stream).await.unwrap();
|
|
let replayed = receive_upstream_request(&mut replay).await;
|
|
send_upstream_response(&mut replay, "resp_recovered", "recovered").await;
|
|
(initial, continued, replayed)
|
|
});
|
|
let (app, api_key, _dir) =
|
|
crate::dispatch::service::test_app_with_account(upstream_base_url).await;
|
|
let server = spawn_app(app).await;
|
|
let mut websocket = connect_responses_websocket(server.address, &api_key).await;
|
|
|
|
let mut first_payload: Value = serde_json::from_str(&response_create_payload("first")).unwrap();
|
|
first_payload["input"][0]["id"] = json!("client_input_id");
|
|
first_payload["input"][0]["encrypted_content"] = json!("client_secret");
|
|
first_payload["input"][0]["tool_arguments"] = json!({
|
|
"id": "user_tool_argument_id",
|
|
"encrypted_content": "user_tool_argument_secret"
|
|
});
|
|
websocket
|
|
.send(Message::Text(first_payload.to_string().into()))
|
|
.await
|
|
.unwrap();
|
|
let first = receive_response(&mut websocket).await;
|
|
assert_eq!(first.last().unwrap()["response"]["id"], "resp_first");
|
|
|
|
let mut second_payload: Value =
|
|
serde_json::from_str(&response_create_payload("second")).unwrap();
|
|
second_payload["previous_response_id"] = json!("resp_first");
|
|
websocket
|
|
.send(Message::Text(second_payload.to_string().into()))
|
|
.await
|
|
.unwrap();
|
|
let second = receive_response(&mut websocket).await;
|
|
let (initial, continued, replayed) = upstream.await.unwrap();
|
|
|
|
assert_eq!(second.last().unwrap()["response"]["id"], "resp_recovered");
|
|
assert_eq!(initial["input"][0]["content"], "first");
|
|
assert_eq!(continued["previous_response_id"], "resp_first");
|
|
assert_eq!(continued["input"][0]["content"], "second");
|
|
assert!(replayed.get("previous_response_id").is_none());
|
|
assert_eq!(replayed["input"][0]["content"], "first");
|
|
assert_eq!(replayed["input"][1]["role"], "assistant");
|
|
assert_eq!(replayed["input"][2]["content"], "second");
|
|
assert_eq!(replayed["input"][0]["id"], "client_input_id");
|
|
assert_eq!(replayed["input"][0]["encrypted_content"], "client_secret");
|
|
assert_eq!(
|
|
replayed["input"][0]["tool_arguments"]["id"],
|
|
"user_tool_argument_id"
|
|
);
|
|
assert_eq!(
|
|
replayed["input"][0]["tool_arguments"]["encrypted_content"],
|
|
"user_tool_argument_secret"
|
|
);
|
|
assert!(replayed["input"][1].get("id").is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn responses_websocket_cross_account_replay_should_isolate_account_bound_state() {
|
|
let upstream_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let upstream_base_url = format!("http://{}", upstream_listener.local_addr().unwrap());
|
|
let upstream = tokio::spawn(async move {
|
|
let (primary_stream, _) = upstream_listener.accept().await.unwrap();
|
|
let (mut primary, primary_headers) =
|
|
accept_websocket_with_request_headers(primary_stream, "Bearer access-primary").await;
|
|
let initial_primary = receive_upstream_request(&mut primary).await;
|
|
send_upstream_response_with_output(
|
|
&mut primary,
|
|
"resp_account_a",
|
|
"answer from A",
|
|
vec![
|
|
json!({
|
|
"type": "message",
|
|
"id": "msg_account_a",
|
|
"role": "assistant",
|
|
"encrypted_content": "encrypted_message_a",
|
|
"content": [{
|
|
"type": "output_text",
|
|
"id": "nested_content_id_a",
|
|
"text": "answer from A"
|
|
}],
|
|
}),
|
|
json!({
|
|
"type": "reasoning",
|
|
"id": "reasoning_account_a",
|
|
"encrypted_content": "encrypted_reasoning_a",
|
|
"summary": [{
|
|
"type": "summary_text",
|
|
"id": "nested_summary_id_a",
|
|
"text": "reasoning summary A"
|
|
}],
|
|
}),
|
|
json!({
|
|
"type": "function_call",
|
|
"id": "function_account_a",
|
|
"call_id": "call_account_a",
|
|
"caller": "client",
|
|
"name": "lookup",
|
|
"arguments": "{\"id\":\"tool_argument_id_a\"}",
|
|
}),
|
|
json!({
|
|
"type": "compaction",
|
|
"id": "compaction_account_a",
|
|
"encrypted_content": "encrypted_compaction_a",
|
|
}),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let (secondary_stream, _) = upstream_listener.accept().await.unwrap();
|
|
let (mut secondary, secondary_headers) =
|
|
accept_websocket_with_request_headers(secondary_stream, "Bearer access-secondary")
|
|
.await;
|
|
let cross_account_replay = receive_upstream_request(&mut secondary).await;
|
|
send_upstream_response_with_output(
|
|
&mut secondary,
|
|
"resp_account_b",
|
|
"answer from B",
|
|
vec![
|
|
json!({
|
|
"type": "message",
|
|
"id": "msg_account_b",
|
|
"role": "assistant",
|
|
"encrypted_content": "encrypted_message_b",
|
|
"content": [{"type": "output_text", "text": "answer from B"}],
|
|
}),
|
|
json!({
|
|
"type": "reasoning",
|
|
"id": "reasoning_account_b",
|
|
"encrypted_content": "encrypted_reasoning_b",
|
|
"summary": [{"type": "summary_text", "text": "reasoning summary B"}],
|
|
}),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let continued_secondary = receive_upstream_request(&mut secondary).await;
|
|
secondary
|
|
.send(Message::Text(
|
|
json!({
|
|
"type": "response.failed",
|
|
"response": {
|
|
"id": "resp_account_b_missing",
|
|
"status": "failed",
|
|
"error": {
|
|
"code": "previous_response_not_found",
|
|
"message": "Previous response with id resp_account_b was not found",
|
|
},
|
|
},
|
|
})
|
|
.to_string()
|
|
.into(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
secondary.close(None).await.unwrap();
|
|
|
|
let (retry_stream, _) = upstream_listener.accept().await.unwrap();
|
|
let (mut retry, retry_headers) =
|
|
accept_websocket_with_request_headers(retry_stream, "Bearer access-secondary").await;
|
|
let same_account_replay = receive_upstream_request(&mut retry).await;
|
|
send_upstream_response(&mut retry, "resp_account_b_recovered", "recovered on B").await;
|
|
|
|
(
|
|
primary_headers,
|
|
secondary_headers,
|
|
retry_headers,
|
|
initial_primary,
|
|
cross_account_replay,
|
|
continued_secondary,
|
|
same_account_replay,
|
|
)
|
|
});
|
|
let (app, state, api_key, pool, _dir) =
|
|
crate::dispatch::service::test_app_with_two_accounts_and_state(upstream_base_url).await;
|
|
let cookie_store = PgCookieStore::new(pool);
|
|
cookie_store
|
|
.capture_set_cookie(
|
|
"acct_primary",
|
|
"cf_clearance=primary; Domain=.chatgpt.com; Path=/codex",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
cookie_store
|
|
.capture_set_cookie(
|
|
"acct_secondary",
|
|
"cf_clearance=secondary; Domain=.chatgpt.com; Path=/codex",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let server = spawn_app(app).await;
|
|
let mut websocket = connect_responses_websocket(server.address, &api_key).await;
|
|
|
|
let mut first_payload: Value = serde_json::from_str(&response_create_payload("first")).unwrap();
|
|
first_payload["input"][0]["id"] = json!("client_input_id");
|
|
first_payload["input"][0]["encrypted_content"] = json!("client_semantic_secret");
|
|
first_payload["input"][0]["tool_arguments"] = json!({"id": "client_tool_argument_id"});
|
|
first_payload["input"].as_array_mut().unwrap().push(json!({
|
|
"type": "reasoning",
|
|
"id": "client_replayed_reasoning_id_a",
|
|
"encrypted_content": "client_replayed_reasoning_encrypted_a",
|
|
"summary": [{
|
|
"type": "summary_text",
|
|
"id": "client_replayed_summary_id_a",
|
|
"text": "client replayed summary A"
|
|
}]
|
|
}));
|
|
first_payload["input"].as_array_mut().unwrap().push(json!({
|
|
"type": "future_output_item",
|
|
"id": "future_output_id_a",
|
|
"encrypted_content": "future_output_encrypted_a",
|
|
"payload": {
|
|
"id": "future_nested_id",
|
|
"encrypted_content": "future_nested_secret"
|
|
}
|
|
}));
|
|
first_payload["turnState"] = json!("turn_state_account_a");
|
|
first_payload["x-codex-installation-id"] = json!("client_installation_a");
|
|
first_payload["client_metadata"] = json!({
|
|
"x-codex-installation-id": "client_installation_a",
|
|
"x-codex-turn-state": "turn_state_account_a"
|
|
});
|
|
websocket
|
|
.send(Message::Text(first_payload.to_string().into()))
|
|
.await
|
|
.unwrap();
|
|
let first = receive_response(&mut websocket).await;
|
|
assert_eq!(first.last().unwrap()["response"]["id"], "resp_account_a");
|
|
|
|
timeout(Duration::from_secs(2), async {
|
|
loop {
|
|
if state
|
|
.services
|
|
.session_affinity
|
|
.lookup("resp_account_a", Utc::now())
|
|
.await
|
|
.is_some()
|
|
{
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("first response affinity should be recorded before removal");
|
|
assert!(
|
|
state
|
|
.services
|
|
.session_affinity
|
|
.forget("resp_account_a")
|
|
.await
|
|
);
|
|
|
|
assert!(
|
|
state
|
|
.services
|
|
.account_pool
|
|
.set_status("acct_primary", AccountStatus::Disabled)
|
|
.await
|
|
);
|
|
let mut second_payload: Value =
|
|
serde_json::from_str(&response_create_payload("second")).unwrap();
|
|
second_payload["previous_response_id"] = json!("resp_account_a");
|
|
second_payload["turnState"] = json!("turn_state_account_a");
|
|
second_payload["turn_state"] = json!("turn_state_account_a_projection");
|
|
second_payload["x-codex-turn-state"] = json!("turn_state_account_a_projection");
|
|
second_payload["turnMetadata"] = json!("{malformed-account-a-metadata");
|
|
second_payload["authorization"] = json!("Bearer leaked-account-a");
|
|
second_payload["cookie"] = json!("session=leaked-account-a");
|
|
second_payload["chatgpt-account-id"] = json!("chatgpt-primary");
|
|
second_payload["account_id"] = json!("acct_primary");
|
|
second_payload["conversation"] = json!("conversation_account_a");
|
|
second_payload["x-codex-installation-id"] = json!("client_installation_a");
|
|
second_payload["installation_id"] = json!("client_installation_a");
|
|
second_payload["installationId"] = json!("client_installation_a");
|
|
second_payload["client_metadata"] = json!({
|
|
"safe": "preserved",
|
|
"account_id": "acct_primary",
|
|
"cookie": "session=leaked-account-a",
|
|
"x-codex-installation-id": "client_installation_a",
|
|
"installation_id": "client_installation_a",
|
|
"installationId": "client_installation_a",
|
|
"x-codex-turn-state": "turn_state_account_a",
|
|
"x-codex-turn-metadata": json!({
|
|
"installation_id": "client_installation_a",
|
|
"account_id": "acct_primary",
|
|
"authorization": "Bearer leaked-account-a",
|
|
"turn_state": "turn_state_account_a",
|
|
"conversation": "conversation_account_a",
|
|
"session_id": "session_semantic"
|
|
}).to_string()
|
|
});
|
|
websocket
|
|
.send(Message::Text(second_payload.to_string().into()))
|
|
.await
|
|
.unwrap();
|
|
let second = receive_response(&mut websocket).await;
|
|
assert_eq!(second.last().unwrap()["response"]["id"], "resp_account_b");
|
|
|
|
let mut third_payload: Value = serde_json::from_str(&response_create_payload("third")).unwrap();
|
|
third_payload["previous_response_id"] = json!("resp_account_b");
|
|
websocket
|
|
.send(Message::Text(third_payload.to_string().into()))
|
|
.await
|
|
.unwrap();
|
|
let third = receive_response(&mut websocket).await;
|
|
assert_eq!(
|
|
third.last().unwrap()["response"]["id"],
|
|
"resp_account_b_recovered"
|
|
);
|
|
|
|
let (
|
|
primary_headers,
|
|
secondary_headers,
|
|
retry_headers,
|
|
initial_primary,
|
|
cross_account_replay,
|
|
continued_secondary,
|
|
same_account_replay,
|
|
) = upstream.await.unwrap();
|
|
assert_eq!(
|
|
request_header(&primary_headers, "authorization"),
|
|
Some("Bearer access-primary")
|
|
);
|
|
assert_eq!(
|
|
request_header(&secondary_headers, "authorization"),
|
|
Some("Bearer access-secondary")
|
|
);
|
|
assert_eq!(
|
|
request_header(&secondary_headers, "chatgpt-account-id"),
|
|
Some("chatgpt-secondary")
|
|
);
|
|
assert_eq!(
|
|
request_header(&secondary_headers, "cookie"),
|
|
Some("cf_clearance=secondary")
|
|
);
|
|
assert_eq!(
|
|
request_header(&retry_headers, "authorization"),
|
|
Some("Bearer access-secondary")
|
|
);
|
|
let primary_installation = request_header(&primary_headers, "x-codex-installation-id").unwrap();
|
|
let secondary_installation =
|
|
request_header(&secondary_headers, "x-codex-installation-id").unwrap();
|
|
assert_ne!(primary_installation, secondary_installation);
|
|
assert_eq!(
|
|
request_header(&retry_headers, "x-codex-installation-id"),
|
|
Some(secondary_installation)
|
|
);
|
|
assert!(request_header(&secondary_headers, "x-codex-turn-state").is_none());
|
|
assert!(request_header(&secondary_headers, "x-codex-turn-metadata").is_none());
|
|
|
|
assert_eq!(initial_primary["turnState"], "turn_state_account_a");
|
|
assert!(cross_account_replay.get("previous_response_id").is_none());
|
|
for key in [
|
|
"turnState",
|
|
"turn_state",
|
|
"x-codex-turn-state",
|
|
"turnMetadata",
|
|
"authorization",
|
|
"cookie",
|
|
"chatgpt-account-id",
|
|
"account_id",
|
|
"conversation",
|
|
] {
|
|
assert!(
|
|
cross_account_replay.get(key).is_none(),
|
|
"cross-account replay leaked top-level field {key}"
|
|
);
|
|
}
|
|
let metadata = &cross_account_replay["client_metadata"];
|
|
assert_eq!(metadata["safe"], "preserved");
|
|
for key in ["account_id", "cookie", "x-codex-turn-state"] {
|
|
assert!(
|
|
metadata.get(key).is_none(),
|
|
"cross-account replay leaked metadata field {key}"
|
|
);
|
|
}
|
|
let scoped_turn_metadata: Value = serde_json::from_str(
|
|
metadata["x-codex-turn-metadata"]
|
|
.as_str()
|
|
.expect("valid client metadata turnMetadata should be retained"),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
scoped_turn_metadata["installation_id"].as_str(),
|
|
Some(secondary_installation)
|
|
);
|
|
assert_eq!(scoped_turn_metadata["session_id"], "session_semantic");
|
|
for key in ["account_id", "authorization", "turn_state", "conversation"] {
|
|
assert!(
|
|
scoped_turn_metadata.get(key).is_none(),
|
|
"cross-account replay leaked turnMetadata field {key}"
|
|
);
|
|
}
|
|
for key in [
|
|
"x-codex-installation-id",
|
|
"installation_id",
|
|
"installationId",
|
|
] {
|
|
assert_eq!(
|
|
cross_account_replay[key].as_str(),
|
|
Some(secondary_installation),
|
|
"top-level installation projection {key}"
|
|
);
|
|
assert_eq!(
|
|
metadata[key].as_str(),
|
|
Some(secondary_installation),
|
|
"metadata installation projection {key}"
|
|
);
|
|
}
|
|
|
|
let replay_input = cross_account_replay["input"].as_array().unwrap();
|
|
assert_eq!(replay_input.len(), 7);
|
|
assert!(replay_input[0].get("id").is_none());
|
|
assert!(replay_input[0].get("encrypted_content").is_none());
|
|
assert_eq!(
|
|
replay_input[0]["tool_arguments"]["id"],
|
|
"client_tool_argument_id"
|
|
);
|
|
assert!(replay_input[1].get("id").is_none());
|
|
assert!(replay_input[1].get("encrypted_content").is_none());
|
|
assert_eq!(
|
|
replay_input[1]["summary"][0]["id"],
|
|
"client_replayed_summary_id_a"
|
|
);
|
|
assert!(replay_input[2].get("id").is_none());
|
|
assert!(replay_input[2].get("encrypted_content").is_none());
|
|
assert_eq!(replay_input[2]["type"], "future_output_item");
|
|
assert_eq!(replay_input[2]["payload"]["id"], "future_nested_id");
|
|
assert_eq!(
|
|
replay_input[2]["payload"]["encrypted_content"],
|
|
"future_nested_secret"
|
|
);
|
|
assert!(replay_input[3].get("id").is_none());
|
|
assert!(replay_input[3].get("encrypted_content").is_none());
|
|
assert_eq!(replay_input[3]["content"][0]["id"], "nested_content_id_a");
|
|
assert!(replay_input[4].get("id").is_none());
|
|
assert!(replay_input[4].get("encrypted_content").is_none());
|
|
assert_eq!(replay_input[4]["summary"][0]["id"], "nested_summary_id_a");
|
|
assert!(replay_input[5].get("id").is_none());
|
|
assert_eq!(replay_input[5]["call_id"], "call_account_a");
|
|
assert_eq!(replay_input[5]["caller"], "client");
|
|
assert_eq!(
|
|
replay_input[5]["arguments"],
|
|
"{\"id\":\"tool_argument_id_a\"}"
|
|
);
|
|
assert_eq!(replay_input[6]["content"], "second");
|
|
let cross_account_json = cross_account_replay.to_string();
|
|
for forbidden in [
|
|
"msg_account_a",
|
|
"reasoning_account_a",
|
|
"function_account_a",
|
|
"compaction_account_a",
|
|
"encrypted_message_a",
|
|
"encrypted_reasoning_a",
|
|
"encrypted_compaction_a",
|
|
"client_replayed_reasoning_id_a",
|
|
"client_replayed_reasoning_encrypted_a",
|
|
"future_output_id_a",
|
|
"future_output_encrypted_a",
|
|
] {
|
|
assert!(
|
|
!cross_account_json.contains(forbidden),
|
|
"cross-account replay leaked {forbidden}"
|
|
);
|
|
}
|
|
|
|
assert_eq!(
|
|
continued_secondary["previous_response_id"],
|
|
"resp_account_b"
|
|
);
|
|
assert_eq!(continued_secondary["input"][0]["content"], "third");
|
|
assert!(same_account_replay.get("previous_response_id").is_none());
|
|
let same_account_json = same_account_replay.to_string();
|
|
assert!(!same_account_json.contains("client_input_id"));
|
|
assert!(!same_account_json.contains("client_semantic_secret"));
|
|
assert!(!same_account_json.contains("encrypted_message_a"));
|
|
assert!(!same_account_json.contains("encrypted_reasoning_a"));
|
|
assert!(!same_account_json.contains("client_replayed_reasoning_encrypted_a"));
|
|
assert!(!same_account_json.contains("future_output_id_a"));
|
|
assert!(!same_account_json.contains("future_output_encrypted_a"));
|
|
assert!(same_account_json.contains("future_nested_id"));
|
|
assert!(same_account_json.contains("future_nested_secret"));
|
|
assert!(same_account_json.contains("encrypted_message_b"));
|
|
assert!(same_account_json.contains("encrypted_reasoning_b"));
|
|
assert!(!same_account_json.contains("msg_account_b"));
|
|
assert!(!same_account_json.contains("reasoning_account_b"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn responses_websocket_history_retry_should_not_fallback_when_owner_account_becomes_unavailable()
|
|
{
|
|
let upstream_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let upstream_base_url = format!("http://{}", upstream_listener.local_addr().unwrap());
|
|
let (continued_tx, continued_rx) = tokio::sync::oneshot::channel();
|
|
let (release_failure_tx, release_failure_rx) = tokio::sync::oneshot::channel();
|
|
let upstream = tokio::spawn(async move {
|
|
let (owner_stream, _) = upstream_listener.accept().await.unwrap();
|
|
let mut owner = accept_upstream_websocket(owner_stream).await.unwrap();
|
|
let initial = receive_upstream_request(&mut owner).await;
|
|
send_upstream_response(&mut owner, "resp_owner", "first answer").await;
|
|
|
|
let continued = receive_upstream_request(&mut owner).await;
|
|
continued_tx.send(()).unwrap();
|
|
release_failure_rx.await.unwrap();
|
|
owner
|
|
.send(Message::Text(
|
|
json!({
|
|
"type": "response.failed",
|
|
"response": {
|
|
"id": "resp_owner_missing",
|
|
"status": "failed",
|
|
"error": {
|
|
"code": "previous_response_not_found",
|
|
"message": "Previous response with id resp_owner was not found",
|
|
},
|
|
},
|
|
})
|
|
.to_string()
|
|
.into(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
owner.close(None).await.unwrap();
|
|
|
|
let fallback = match timeout(Duration::from_millis(700), upstream_listener.accept()).await {
|
|
Ok(Ok((fallback_stream, _))) => {
|
|
let mut fallback = accept_upstream_websocket(fallback_stream).await.unwrap();
|
|
let request = receive_upstream_request(&mut fallback).await;
|
|
send_upstream_response(&mut fallback, "resp_wrong_fallback", "wrong account").await;
|
|
Some(request)
|
|
}
|
|
Ok(Err(error)) => panic!("fallback listener failed: {error}"),
|
|
Err(_) => None,
|
|
};
|
|
(initial, continued, fallback)
|
|
});
|
|
let (app, state, api_key, _pool, _dir) =
|
|
crate::dispatch::service::test_app_with_two_accounts_and_state(upstream_base_url).await;
|
|
let server = spawn_app(app).await;
|
|
let mut websocket = connect_responses_websocket(server.address, &api_key).await;
|
|
|
|
websocket
|
|
.send(Message::Text(response_create_payload("first").into()))
|
|
.await
|
|
.unwrap();
|
|
let first = receive_response(&mut websocket).await;
|
|
let mut continued_payload: Value =
|
|
serde_json::from_str(&response_create_payload("second")).unwrap();
|
|
continued_payload["previous_response_id"] = json!("resp_owner");
|
|
websocket
|
|
.send(Message::Text(continued_payload.to_string().into()))
|
|
.await
|
|
.unwrap();
|
|
continued_rx.await.unwrap();
|
|
assert!(
|
|
state
|
|
.services
|
|
.account_pool
|
|
.set_status("acct_primary", AccountStatus::Expired)
|
|
.await
|
|
);
|
|
release_failure_tx.send(()).unwrap();
|
|
let second = receive_response(&mut websocket).await;
|
|
let (initial, continued, fallback) = upstream.await.unwrap();
|
|
|
|
assert_eq!(first.last().unwrap()["response"]["id"], "resp_owner");
|
|
assert_eq!(initial["input"][0]["content"], "first");
|
|
assert_eq!(continued["previous_response_id"], "resp_owner");
|
|
assert_eq!(second.last().unwrap()["type"], "error");
|
|
assert_eq!(
|
|
second.last().unwrap()["error"]["code"],
|
|
"previous_response_not_found"
|
|
);
|
|
assert!(
|
|
fallback.is_none(),
|
|
"same-account retry must not fall back to another candidate: {fallback:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn responses_websocket_should_allow_official_retry_after_midstream_upstream_disconnect() {
|
|
let upstream_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let upstream_base_url = format!("http://{}", upstream_listener.local_addr().unwrap());
|
|
let upstream = tokio::spawn(async move {
|
|
let (first_stream, _) = upstream_listener.accept().await.unwrap();
|
|
let mut first = accept_upstream_websocket(first_stream).await.unwrap();
|
|
let _request = first.next().await.unwrap().unwrap();
|
|
first
|
|
.send(Message::Text(
|
|
json!({
|
|
"type": "response.output_text.delta",
|
|
"delta": "partial",
|
|
})
|
|
.to_string()
|
|
.into(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
drop(first);
|
|
|
|
let (second_stream, _) = upstream_listener.accept().await.unwrap();
|
|
let mut second = accept_upstream_websocket(second_stream).await.unwrap();
|
|
let _request = second.next().await.unwrap().unwrap();
|
|
send_upstream_response(&mut second, "resp_retry", "recovered").await;
|
|
});
|
|
let (app, api_key, _dir) =
|
|
crate::dispatch::service::test_app_with_account(upstream_base_url).await;
|
|
let server = spawn_app(app).await;
|
|
|
|
let mut first = connect_responses_websocket(server.address, &api_key).await;
|
|
first
|
|
.send(Message::Text(response_create_payload("retry me").into()))
|
|
.await
|
|
.unwrap();
|
|
let failed = receive_response(&mut first).await;
|
|
drop(first);
|
|
|
|
let mut second = connect_responses_websocket(server.address, &api_key).await;
|
|
second
|
|
.send(Message::Text(response_create_payload("retry me").into()))
|
|
.await
|
|
.unwrap();
|
|
let recovered = receive_response(&mut second).await;
|
|
upstream.await.unwrap();
|
|
|
|
assert!(response_event_types(&failed).contains(&"response.output_text.delta"));
|
|
assert_eq!(failed.last().unwrap()["type"], "response.failed");
|
|
assert_eq!(
|
|
failed.last().unwrap()["response"]["error"]["code"],
|
|
"stream_disconnected"
|
|
);
|
|
assert_eq!(recovered.last().unwrap()["type"], "response.completed");
|
|
}
|
|
|
|
async fn spawn_app(app: Router) -> TestServer {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let address = listener.local_addr().unwrap();
|
|
let task = tokio::spawn(async move {
|
|
axum::serve(listener, app).await.unwrap();
|
|
});
|
|
TestServer { address, task }
|
|
}
|
|
|
|
async fn connect_responses_websocket(
|
|
address: SocketAddr,
|
|
api_key: &str,
|
|
) -> WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> {
|
|
let mut request = format!("ws://{address}/v1/responses")
|
|
.into_client_request()
|
|
.unwrap();
|
|
request.headers_mut().insert(
|
|
AUTHORIZATION,
|
|
HeaderValue::from_str(&format!("Bearer {api_key}")).unwrap(),
|
|
);
|
|
connect_async(request).await.unwrap().0
|
|
}
|
|
|
|
fn response_create_payload(content: &str) -> String {
|
|
json!({
|
|
"type": "response.create",
|
|
"model": "gpt-5.5",
|
|
"instructions": "test",
|
|
"input": [{"role": "user", "content": content}],
|
|
"tools": [],
|
|
"tool_choice": "auto",
|
|
"parallel_tool_calls": true,
|
|
"reasoning": {"effort": "medium"},
|
|
"prompt_cache_key": "downstream-websocket-test",
|
|
"store": false,
|
|
"stream": true,
|
|
"include": [],
|
|
})
|
|
.to_string()
|
|
}
|
|
|
|
async fn send_upstream_response(
|
|
websocket: &mut WebSocketStream<tokio::net::TcpStream>,
|
|
response_id: &str,
|
|
text: &str,
|
|
) {
|
|
send_upstream_response_with_output(
|
|
websocket,
|
|
response_id,
|
|
text,
|
|
vec![json!({
|
|
"type": "message",
|
|
"id": format!("assistant_output_{response_id}"),
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": text}],
|
|
})],
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn send_upstream_response_with_output(
|
|
websocket: &mut WebSocketStream<tokio::net::TcpStream>,
|
|
response_id: &str,
|
|
text: &str,
|
|
output: Vec<Value>,
|
|
) {
|
|
for event in [
|
|
json!({
|
|
"type": "response.created",
|
|
"response": {"id": response_id, "status": "in_progress"},
|
|
}),
|
|
json!({
|
|
"type": "response.output_text.delta",
|
|
"delta": text,
|
|
}),
|
|
json!({
|
|
"type": "response.completed",
|
|
"response": {
|
|
"id": response_id,
|
|
"object": "response",
|
|
"status": "completed",
|
|
"output": output,
|
|
"usage": {
|
|
"input_tokens": 3,
|
|
"output_tokens": 1,
|
|
"total_tokens": 4,
|
|
},
|
|
},
|
|
}),
|
|
] {
|
|
websocket
|
|
.send(Message::Text(event.to_string().into()))
|
|
.await
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
async fn receive_upstream_request(websocket: &mut WebSocketStream<tokio::net::TcpStream>) -> Value {
|
|
let request = websocket.next().await.unwrap().unwrap();
|
|
serde_json::from_str(&request.into_text().unwrap()).unwrap()
|
|
}
|
|
|
|
fn request_header<'a>(
|
|
headers: &'a tokio_tungstenite::tungstenite::http::HeaderMap,
|
|
name: &str,
|
|
) -> Option<&'a str> {
|
|
headers.get(name).and_then(|value| value.to_str().ok())
|
|
}
|
|
|
|
async fn receive_response<S>(websocket: &mut WebSocketStream<S>) -> Vec<Value>
|
|
where
|
|
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
|
{
|
|
let mut events = Vec::new();
|
|
loop {
|
|
let message = timeout(Duration::from_secs(5), websocket.next())
|
|
.await
|
|
.expect("timed out waiting for Responses WebSocket event")
|
|
.expect("Responses WebSocket closed before a terminal event")
|
|
.expect("failed to receive Responses WebSocket event");
|
|
let Message::Text(payload) = message else {
|
|
continue;
|
|
};
|
|
let event = serde_json::from_str::<Value>(&payload).unwrap();
|
|
let terminal = matches!(
|
|
event.get("type").and_then(Value::as_str),
|
|
Some("response.completed" | "response.incomplete" | "response.failed" | "error")
|
|
);
|
|
events.push(event);
|
|
if terminal {
|
|
return events;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn response_event_types(events: &[Value]) -> Vec<&str> {
|
|
events
|
|
.iter()
|
|
.filter_map(|event| event.get("type").and_then(Value::as_str))
|
|
.collect()
|
|
}
|