mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
14df0e8833
## What Introduce a `CodexResponsesMetadata` struct that defines all the core metadata we send to Responses API. Example fields are `thread_id`, `turn_id`, `window_id`, etc. Going forward, `client_metadata["x-codex-turn-metadata"]` will be the canonical way Codex sends metadata to Responses API across both HTTP and websocket transports. For now, we continue to emit the existing top-level HTTP headers and top-level `client_metadata` fields from the same `CodexResponsesMetadata` struct for compatibility reasons. Also, app-server clients who specify additional `responsesapi_client_metadata` via `turn/start` and `turn/steer` will have those fields merged into `client_metadata["x-codex-turn-metadata"]`, but cannot override the reserved fields that core uses (i.e. the fields in `CodexResponsesMetadata`). ## Why Responses API request instrumentation is the source of truth for downstream Codex analytics that join requests by Codex IDs such as session, thread, turn, and context window. Before this change, those values were assembled through several request-specific paths: HTTP request bodies, websocket handshake headers, websocket `response.create` payloads, compaction requests, and the rich `x-codex-turn-metadata` envelope all had their own wiring. That made metadata propagation easy to drift across API-key/direct Responses API requests, ChatGPT-auth/proxied requests, websocket requests, and compaction requests. It also made additions like `window_id` error-prone because a field could be added to one transport projection but missed in another. ## What changed - Added `CodexResponsesMetadata` as the core-owned snapshot for Codex metadata sent to ResponsesAPI. - Render `client_metadata["x-codex-turn-metadata"]`, flat `client_metadata` projections, and direct compatibility headers from that same snapshot. - Include the known Codex-owned fields in the turn metadata blob, including installation/session/thread/turn/window IDs, request kind, lineage, sandbox/workspace metadata, timing, and compaction details. - Treat app-server `responsesapi_client_metadata` as enrichment for the Codex turn metadata blob while preventing those extras from overriding Codex-owned fields. - Use the same metadata path for normal turns, websocket prewarm, local compaction, remote v1 compaction, and remote v2 compaction. - Keep websocket connection-only preconnect metadata separate so handshakes carry compatibility identity headers without inventing a fake turn metadata blob. ## Verification - `cargo check -p codex-core` - `just fix -p codex-core`
2298 lines
78 KiB
Rust
Executable File
2298 lines
78 KiB
Rust
Executable File
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
|
use codex_api::WS_REQUEST_HEADER_TRACEPARENT_CLIENT_METADATA_KEY;
|
|
use codex_api::WS_REQUEST_HEADER_TRACESTATE_CLIENT_METADATA_KEY;
|
|
use codex_core::CodexResponsesMetadata;
|
|
use codex_core::ModelClient;
|
|
use codex_core::ModelClientSession;
|
|
use codex_core::Prompt;
|
|
use codex_core::ResponseEvent;
|
|
use codex_core::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER;
|
|
use codex_features::Feature;
|
|
use codex_login::CodexAuth;
|
|
use codex_model_provider_info::ModelProviderInfo;
|
|
use codex_model_provider_info::WireApi;
|
|
use codex_otel::MetricsClient;
|
|
use codex_otel::MetricsConfig;
|
|
use codex_otel::SessionTelemetry;
|
|
use codex_otel::TelemetryAuthMode;
|
|
use codex_otel::current_span_w3c_trace_context;
|
|
use codex_protocol::SessionId;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::account::PlanType;
|
|
use codex_protocol::config_types::ReasoningSummary;
|
|
use codex_protocol::config_types::ServiceTier;
|
|
use codex_protocol::models::BaseInstructions;
|
|
use codex_protocol::models::ContentItem;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::openai_models::ModelInfo;
|
|
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::Op;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_protocol::protocol::W3cTraceContext;
|
|
use codex_protocol::user_input::UserInput;
|
|
use codex_rollout_trace::ConversationPart;
|
|
use codex_rollout_trace::InferenceTraceContext;
|
|
use codex_rollout_trace::RawTraceEventPayload;
|
|
use codex_rollout_trace::TraceWriter;
|
|
use codex_rollout_trace::replay_bundle;
|
|
use core_test_support::TestCodexResponsesRequestKind;
|
|
use core_test_support::load_default_config_for_test;
|
|
use core_test_support::responses::WebSocketConnectionConfig;
|
|
use core_test_support::responses::WebSocketTestServer;
|
|
use core_test_support::responses::ev_assistant_message;
|
|
use core_test_support::responses::ev_completed;
|
|
use core_test_support::responses::ev_response_created;
|
|
use core_test_support::responses::start_websocket_server;
|
|
use core_test_support::responses::start_websocket_server_with_headers;
|
|
use core_test_support::responses_metadata as test_responses_metadata;
|
|
use core_test_support::skip_if_no_network;
|
|
use core_test_support::test_codex::test_codex;
|
|
use core_test_support::tracing::install_test_tracing;
|
|
use core_test_support::wait_for_event;
|
|
use futures::StreamExt;
|
|
use opentelemetry_sdk::metrics::InMemoryMetricExporter;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use tempfile::TempDir;
|
|
use tracing::Instrument;
|
|
use tracing_test::traced_test;
|
|
|
|
const MODEL: &str = "gpt-5.3-codex";
|
|
const OPENAI_BETA_HEADER: &str = "OpenAI-Beta";
|
|
const USER_AGENT_HEADER: &str = "user-agent";
|
|
const WS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06";
|
|
const X_CLIENT_REQUEST_ID_HEADER: &str = "x-client-request-id";
|
|
const WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY: &str =
|
|
"ws_request_header_x_openai_internal_codex_responses_lite";
|
|
const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111";
|
|
const TEST_WINDOW_ID: &str = "test-thread:0";
|
|
const X_CODEX_WS_STREAM_REQUEST_START_MS_CLIENT_METADATA_KEY: &str =
|
|
"x-codex-ws-stream-request-start-ms";
|
|
|
|
fn assert_request_trace_matches(body: &serde_json::Value, expected_trace: &W3cTraceContext) {
|
|
let client_metadata = body["client_metadata"]
|
|
.as_object()
|
|
.expect("missing client_metadata payload");
|
|
let actual_traceparent = client_metadata
|
|
.get(WS_REQUEST_HEADER_TRACEPARENT_CLIENT_METADATA_KEY)
|
|
.and_then(serde_json::Value::as_str)
|
|
.expect("missing traceparent");
|
|
let expected_traceparent = expected_trace
|
|
.traceparent
|
|
.as_deref()
|
|
.expect("missing expected traceparent");
|
|
|
|
assert_eq!(actual_traceparent, expected_traceparent);
|
|
assert_eq!(
|
|
client_metadata
|
|
.get(WS_REQUEST_HEADER_TRACESTATE_CLIENT_METADATA_KEY)
|
|
.and_then(serde_json::Value::as_str),
|
|
expected_trace.tracestate.as_deref()
|
|
);
|
|
assert!(
|
|
body.get("trace").is_none(),
|
|
"top-level trace should not be sent"
|
|
);
|
|
}
|
|
|
|
struct WebsocketTestHarness {
|
|
_codex_home: TempDir,
|
|
client: ModelClient,
|
|
session_id: SessionId,
|
|
thread_id: ThreadId,
|
|
model_info: ModelInfo,
|
|
effort: Option<ReasoningEffortConfig>,
|
|
summary: ReasoningSummary,
|
|
session_telemetry: SessionTelemetry,
|
|
}
|
|
|
|
fn responses_metadata(
|
|
harness: &WebsocketTestHarness,
|
|
turn_id: Option<&str>,
|
|
request_kind: TestCodexResponsesRequestKind,
|
|
) -> CodexResponsesMetadata {
|
|
test_responses_metadata(
|
|
TEST_INSTALLATION_ID,
|
|
&harness.session_id.to_string(),
|
|
&harness.thread_id.to_string(),
|
|
turn_id,
|
|
TEST_WINDOW_ID.to_string(),
|
|
&SessionSource::Exec,
|
|
/*parent_thread_id*/ None,
|
|
request_kind,
|
|
)
|
|
}
|
|
|
|
fn turn_metadata(harness: &WebsocketTestHarness, turn_id: Option<&str>) -> CodexResponsesMetadata {
|
|
responses_metadata(harness, turn_id, TestCodexResponsesRequestKind::Turn)
|
|
}
|
|
|
|
fn prewarm_metadata(
|
|
harness: &WebsocketTestHarness,
|
|
turn_id: Option<&str>,
|
|
) -> CodexResponsesMetadata {
|
|
responses_metadata(harness, turn_id, TestCodexResponsesRequestKind::Prewarm)
|
|
}
|
|
|
|
fn websocket_connection_metadata(harness: &WebsocketTestHarness) -> CodexResponsesMetadata {
|
|
responses_metadata(
|
|
harness,
|
|
/*turn_id*/ None,
|
|
TestCodexResponsesRequestKind::WebsocketConnection,
|
|
)
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_streams_request() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![vec![
|
|
ev_response_created("resp-1"),
|
|
ev_completed("resp-1"),
|
|
]]])
|
|
.await;
|
|
|
|
let harness = websocket_harness(&server).await;
|
|
let mut client_session = harness.client.new_session();
|
|
let prompt = prompt_with_input(vec![message_item("hello")]);
|
|
|
|
stream_until_complete(&mut client_session, &harness, &prompt).await;
|
|
|
|
let connection = server.single_connection();
|
|
assert_eq!(connection.len(), 1);
|
|
let body = connection.first().expect("missing request").body_json();
|
|
|
|
assert_eq!(body["type"].as_str(), Some("response.create"));
|
|
assert_eq!(body["model"].as_str(), Some(MODEL));
|
|
assert_eq!(body["stream"], serde_json::Value::Bool(true));
|
|
assert_eq!(body["input"].as_array().map(Vec::len), Some(1));
|
|
let handshake = server.single_handshake();
|
|
assert_eq!(
|
|
handshake.header(OPENAI_BETA_HEADER),
|
|
Some(WS_V2_BETA_HEADER_VALUE.to_string())
|
|
);
|
|
assert_eq!(
|
|
handshake.header(X_CLIENT_REQUEST_ID_HEADER),
|
|
Some(harness.thread_id.to_string())
|
|
);
|
|
assert_eq!(
|
|
handshake.header("session-id"),
|
|
Some(harness.session_id.to_string())
|
|
);
|
|
assert_eq!(
|
|
handshake.header("thread-id"),
|
|
Some(harness.thread_id.to_string())
|
|
);
|
|
assert_eq!(
|
|
handshake.header(USER_AGENT_HEADER),
|
|
Some(codex_login::default_client::get_codex_user_agent())
|
|
);
|
|
assert_eq!(
|
|
body["client_metadata"]["x-codex-installation-id"].as_str(),
|
|
Some(TEST_INSTALLATION_ID)
|
|
);
|
|
let stream_request_start_ms = body["client_metadata"]
|
|
[X_CODEX_WS_STREAM_REQUEST_START_MS_CLIENT_METADATA_KEY]
|
|
.as_str()
|
|
.expect("missing websocket stream request start timestamp")
|
|
.parse::<i64>()
|
|
.expect("websocket stream request start timestamp should be an integer");
|
|
assert!(stream_request_start_ms > 0);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_streams_without_feature_flag_when_provider_supports_websockets() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![vec![
|
|
ev_response_created("resp-1"),
|
|
ev_completed("resp-1"),
|
|
]]])
|
|
.await;
|
|
|
|
let harness = websocket_harness_with_options(&server, /*runtime_metrics_enabled*/ false).await;
|
|
let mut client_session = harness.client.new_session();
|
|
let prompt = prompt_with_input(vec![message_item("hello")]);
|
|
|
|
stream_until_complete(&mut client_session, &harness, &prompt).await;
|
|
|
|
assert_eq!(server.handshakes().len(), 1);
|
|
assert_eq!(server.single_connection().len(), 1);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_reuses_connection_with_per_turn_trace_payloads() {
|
|
skip_if_no_network!();
|
|
|
|
let _trace_test_context = install_test_tracing("client-websocket-test");
|
|
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![ev_response_created("resp-1"), ev_completed("resp-1")],
|
|
vec![ev_response_created("resp-2"), ev_completed("resp-2")],
|
|
]])
|
|
.await;
|
|
|
|
let harness = websocket_harness(&server).await;
|
|
let prompt_one = prompt_with_input(vec![message_item("hello")]);
|
|
let prompt_two = prompt_with_input(vec![message_item("again")]);
|
|
|
|
let first_trace = {
|
|
let mut client_session = harness.client.new_session();
|
|
async {
|
|
let expected_trace =
|
|
current_span_w3c_trace_context().expect("current span should have trace context");
|
|
stream_until_complete(&mut client_session, &harness, &prompt_one).await;
|
|
expected_trace
|
|
}
|
|
.instrument(tracing::info_span!("client.websocket.turn_one"))
|
|
.await
|
|
};
|
|
|
|
let second_trace = {
|
|
let mut client_session = harness.client.new_session();
|
|
async {
|
|
let expected_trace =
|
|
current_span_w3c_trace_context().expect("current span should have trace context");
|
|
stream_until_complete(&mut client_session, &harness, &prompt_two).await;
|
|
expected_trace
|
|
}
|
|
.instrument(tracing::info_span!("client.websocket.turn_two"))
|
|
.await
|
|
};
|
|
|
|
assert_eq!(server.handshakes().len(), 1);
|
|
assert_eq!(
|
|
server.single_handshake().header(USER_AGENT_HEADER),
|
|
Some(codex_login::default_client::get_codex_user_agent())
|
|
);
|
|
let connection = server.single_connection();
|
|
assert_eq!(connection.len(), 2);
|
|
|
|
let first_request = connection
|
|
.first()
|
|
.expect("missing first request")
|
|
.body_json();
|
|
let second_request = connection
|
|
.get(1)
|
|
.expect("missing second request")
|
|
.body_json();
|
|
assert_request_trace_matches(&first_request, &first_trace);
|
|
assert_request_trace_matches(&second_request, &second_trace);
|
|
|
|
let first_traceparent = first_request["client_metadata"]
|
|
[WS_REQUEST_HEADER_TRACEPARENT_CLIENT_METADATA_KEY]
|
|
.as_str()
|
|
.expect("missing first traceparent");
|
|
let second_traceparent = second_request["client_metadata"]
|
|
[WS_REQUEST_HEADER_TRACEPARENT_CLIENT_METADATA_KEY]
|
|
.as_str()
|
|
.expect("missing second traceparent");
|
|
assert_ne!(first_traceparent, second_traceparent);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_preconnect_does_not_replace_turn_trace_payload() {
|
|
skip_if_no_network!();
|
|
|
|
let _trace_test_context = install_test_tracing("client-websocket-test");
|
|
|
|
let server = start_websocket_server(vec![vec![vec![
|
|
ev_response_created("resp-1"),
|
|
ev_completed("resp-1"),
|
|
]]])
|
|
.await;
|
|
|
|
let harness = websocket_harness(&server).await;
|
|
let mut client_session = harness.client.new_session();
|
|
let responses_metadata = websocket_connection_metadata(&harness);
|
|
client_session
|
|
.preconnect_websocket(&harness.session_telemetry, &responses_metadata)
|
|
.await
|
|
.expect("websocket preconnect failed");
|
|
let prompt = prompt_with_input(vec![message_item("hello")]);
|
|
|
|
let expected_trace = async {
|
|
let expected_trace =
|
|
current_span_w3c_trace_context().expect("current span should have trace context");
|
|
stream_until_complete(&mut client_session, &harness, &prompt).await;
|
|
expected_trace
|
|
}
|
|
.instrument(tracing::info_span!("client.websocket.request"))
|
|
.await;
|
|
|
|
assert_eq!(server.handshakes().len(), 1);
|
|
let connection = server.single_connection();
|
|
assert_eq!(connection.len(), 1);
|
|
let request = connection.first().expect("missing request").body_json();
|
|
assert_request_trace_matches(&request, &expected_trace);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_preconnect_reuses_connection() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![vec![
|
|
ev_response_created("resp-1"),
|
|
ev_completed("resp-1"),
|
|
]]])
|
|
.await;
|
|
|
|
let harness = websocket_harness(&server).await;
|
|
let mut client_session = harness.client.new_session();
|
|
let responses_metadata = websocket_connection_metadata(&harness);
|
|
client_session
|
|
.preconnect_websocket(&harness.session_telemetry, &responses_metadata)
|
|
.await
|
|
.expect("websocket preconnect failed");
|
|
let prompt = prompt_with_input(vec![message_item("hello")]);
|
|
stream_until_complete(&mut client_session, &harness, &prompt).await;
|
|
|
|
assert_eq!(server.handshakes().len(), 1);
|
|
assert_eq!(
|
|
server.single_handshake().header(USER_AGENT_HEADER),
|
|
Some(codex_login::default_client::get_codex_user_agent())
|
|
);
|
|
assert_eq!(
|
|
server.single_handshake().header("x-codex-window-id"),
|
|
Some(TEST_WINDOW_ID.to_string())
|
|
);
|
|
let connection = server.single_connection();
|
|
assert_eq!(connection.len(), 1);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_request_prewarm_reuses_connection() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![ev_response_created("warm-1"), ev_completed("warm-1")],
|
|
vec![ev_response_created("resp-1"), ev_completed("resp-1")],
|
|
]])
|
|
.await;
|
|
|
|
let harness = websocket_harness_with_options(&server, /*runtime_metrics_enabled*/ true).await;
|
|
let mut client_session = harness.client.new_session();
|
|
let prompt = prompt_with_input(vec![message_item("hello")]);
|
|
let responses_metadata = prewarm_metadata(&harness, /*turn_id*/ None);
|
|
client_session
|
|
.prewarm_websocket(
|
|
&prompt,
|
|
&harness.model_info,
|
|
&harness.session_telemetry,
|
|
harness.effort.clone(),
|
|
harness.summary,
|
|
/*service_tier*/ None,
|
|
&responses_metadata,
|
|
)
|
|
.await
|
|
.expect("websocket prewarm failed");
|
|
stream_until_complete(&mut client_session, &harness, &prompt).await;
|
|
|
|
assert_eq!(server.handshakes().len(), 1);
|
|
assert_eq!(
|
|
server.single_handshake().header(USER_AGENT_HEADER),
|
|
Some(codex_login::default_client::get_codex_user_agent())
|
|
);
|
|
let connection = server.single_connection();
|
|
assert_eq!(connection.len(), 2);
|
|
let warmup = connection
|
|
.first()
|
|
.expect("missing warmup request")
|
|
.body_json();
|
|
let follow_up = connection
|
|
.get(1)
|
|
.expect("missing follow-up request")
|
|
.body_json();
|
|
|
|
assert_eq!(warmup["type"].as_str(), Some("response.create"));
|
|
assert_eq!(warmup["generate"].as_bool(), Some(false));
|
|
assert_eq!(warmup["tools"], serde_json::json!([]));
|
|
let warmup_turn_metadata: serde_json::Value = serde_json::from_str(
|
|
warmup["client_metadata"]["x-codex-turn-metadata"]
|
|
.as_str()
|
|
.expect("warmup turn metadata"),
|
|
)
|
|
.expect("valid warmup turn metadata");
|
|
assert_eq!(
|
|
warmup_turn_metadata["request_kind"].as_str(),
|
|
Some("prewarm")
|
|
);
|
|
assert_eq!(follow_up["type"].as_str(), Some("response.create"));
|
|
assert_eq!(follow_up["previous_response_id"].as_str(), Some("warm-1"));
|
|
assert_eq!(follow_up["input"], serde_json::json!([]));
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_request_prewarm_uses_caller_supplied_metadata() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![vec![
|
|
ev_response_created("warm-1"),
|
|
ev_completed("warm-1"),
|
|
]]])
|
|
.await;
|
|
|
|
let harness = websocket_harness_with_options(&server, /*runtime_metrics_enabled*/ true).await;
|
|
let mut client_session = harness.client.new_session();
|
|
let prompt = prompt_with_input(vec![message_item("hello")]);
|
|
let responses_metadata = turn_metadata(&harness, /*turn_id*/ None);
|
|
client_session
|
|
.prewarm_websocket(
|
|
&prompt,
|
|
&harness.model_info,
|
|
&harness.session_telemetry,
|
|
harness.effort.clone(),
|
|
harness.summary,
|
|
/*service_tier*/ None,
|
|
&responses_metadata,
|
|
)
|
|
.await
|
|
.expect("websocket prewarm failed");
|
|
|
|
let warmup = server
|
|
.single_connection()
|
|
.first()
|
|
.expect("missing warmup request")
|
|
.body_json();
|
|
let warmup_turn_metadata: serde_json::Value = serde_json::from_str(
|
|
warmup["client_metadata"]["x-codex-turn-metadata"]
|
|
.as_str()
|
|
.expect("warmup turn metadata"),
|
|
)
|
|
.expect("valid warmup turn metadata");
|
|
assert_eq!(warmup_turn_metadata["request_kind"].as_str(), Some("turn"));
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_request_prewarm_traces_logical_request() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![ev_response_created("warm-1"), ev_completed("warm-1")],
|
|
vec![ev_response_created("resp-1"), ev_completed("resp-1")],
|
|
]])
|
|
.await;
|
|
|
|
let harness = websocket_harness_with_options(&server, /*runtime_metrics_enabled*/ true).await;
|
|
let mut client_session = harness.client.new_session();
|
|
let prompt = prompt_with_input(vec![message_item("hello")]);
|
|
let prewarm_responses_metadata = prewarm_metadata(&harness, /*turn_id*/ None);
|
|
|
|
client_session
|
|
.prewarm_websocket(
|
|
&prompt,
|
|
&harness.model_info,
|
|
&harness.session_telemetry,
|
|
harness.effort.clone(),
|
|
harness.summary,
|
|
/*service_tier*/ None,
|
|
&prewarm_responses_metadata,
|
|
)
|
|
.await
|
|
.expect("websocket prewarm failed");
|
|
|
|
let trace_dir = TempDir::new().expect("trace dir");
|
|
let writer = Arc::new(
|
|
TraceWriter::create(
|
|
trace_dir.path(),
|
|
"trace-1".to_string(),
|
|
harness.session_id.to_string(),
|
|
harness.thread_id.to_string(),
|
|
)
|
|
.expect("trace writer"),
|
|
);
|
|
writer
|
|
.append(RawTraceEventPayload::ThreadStarted {
|
|
thread_id: harness.thread_id.to_string(),
|
|
agent_path: "/root".to_string(),
|
|
metadata_payload: None,
|
|
})
|
|
.expect("thread started");
|
|
writer
|
|
.append(RawTraceEventPayload::CodexTurnStarted {
|
|
codex_turn_id: "turn-1".to_string(),
|
|
thread_id: harness.thread_id.to_string(),
|
|
})
|
|
.expect("turn started");
|
|
|
|
let inference_trace = InferenceTraceContext::enabled(
|
|
writer,
|
|
harness.thread_id.to_string(),
|
|
"turn-1".to_string(),
|
|
harness.model_info.slug.clone(),
|
|
"test-provider".to_string(),
|
|
);
|
|
|
|
let responses_metadata = turn_metadata(&harness, /*turn_id*/ None);
|
|
let mut stream = client_session
|
|
.stream(
|
|
&prompt,
|
|
&harness.model_info,
|
|
&harness.session_telemetry,
|
|
harness.effort.clone(),
|
|
harness.summary,
|
|
/*service_tier*/ None,
|
|
&responses_metadata,
|
|
&inference_trace,
|
|
)
|
|
.await
|
|
.expect("websocket stream failed");
|
|
|
|
while let Some(event) = stream.next().await {
|
|
if matches!(event, Ok(ResponseEvent::Completed { .. })) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
let connection = server.single_connection();
|
|
let follow_up = connection
|
|
.get(1)
|
|
.expect("missing follow-up request")
|
|
.body_json();
|
|
assert_eq!(follow_up["previous_response_id"].as_str(), Some("warm-1"));
|
|
assert_eq!(follow_up["input"], serde_json::json!([]));
|
|
|
|
let rollout = replay_bundle(trace_dir.path()).expect("replay trace");
|
|
let inference = rollout
|
|
.inference_calls
|
|
.values()
|
|
.next()
|
|
.expect("inference should be present");
|
|
assert_eq!(inference.request_item_ids.len(), 1);
|
|
assert_eq!(
|
|
rollout.conversation_items[&inference.request_item_ids[0]]
|
|
.body
|
|
.parts,
|
|
vec![ConversationPart::Text {
|
|
text: "hello".to_string(),
|
|
}],
|
|
);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_reuses_connection_after_session_drop() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![ev_response_created("resp-1"), ev_completed("resp-1")],
|
|
vec![ev_response_created("resp-2"), ev_completed("resp-2")],
|
|
]])
|
|
.await;
|
|
|
|
let harness = websocket_harness(&server).await;
|
|
let prompt_one = prompt_with_input(vec![message_item("hello")]);
|
|
let prompt_two = prompt_with_input(vec![message_item("again")]);
|
|
|
|
{
|
|
let mut client_session = harness.client.new_session();
|
|
stream_until_complete(&mut client_session, &harness, &prompt_one).await;
|
|
}
|
|
|
|
let mut client_session = harness.client.new_session();
|
|
stream_until_complete(&mut client_session, &harness, &prompt_two).await;
|
|
|
|
assert_eq!(server.handshakes().len(), 1);
|
|
assert_eq!(server.single_connection().len(), 2);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_sends_responses_lite_metadata_per_request() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![ev_response_created("normal-1"), ev_completed("normal-1")],
|
|
vec![ev_response_created("lite-1"), ev_completed("lite-1")],
|
|
vec![ev_response_created("normal-2"), ev_completed("normal-2")],
|
|
]])
|
|
.await;
|
|
|
|
let harness = websocket_harness(&server).await;
|
|
let mut normal_model_info = harness.model_info.clone();
|
|
normal_model_info.supports_reasoning_summaries = true;
|
|
let mut lite_model_info = normal_model_info.clone();
|
|
lite_model_info.use_responses_lite = true;
|
|
let mut session = harness.client.new_session();
|
|
|
|
stream_until_complete_with_model_info(
|
|
&mut session,
|
|
&harness,
|
|
&prompt_with_input(vec![message_item("normal one")]),
|
|
&normal_model_info,
|
|
"normal-1",
|
|
)
|
|
.await;
|
|
stream_until_complete_with_model_info(
|
|
&mut session,
|
|
&harness,
|
|
&prompt_with_input(vec![message_item("lite")]),
|
|
&lite_model_info,
|
|
"lite-1",
|
|
)
|
|
.await;
|
|
stream_until_complete_with_model_info(
|
|
&mut session,
|
|
&harness,
|
|
&prompt_with_input(vec![message_item("normal two")]),
|
|
&normal_model_info,
|
|
"normal-2",
|
|
)
|
|
.await;
|
|
|
|
let connection = server.single_connection();
|
|
assert_eq!(
|
|
connection
|
|
.iter()
|
|
.map(|request| {
|
|
let body = request.body_json();
|
|
json!({
|
|
"responses_lite": body["client_metadata"]
|
|
.get(WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY),
|
|
"reasoning_context": body["reasoning"].get("context"),
|
|
"parallel_tool_calls": body["parallel_tool_calls"],
|
|
})
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
json!({
|
|
"responses_lite": null,
|
|
"reasoning_context": null,
|
|
"parallel_tool_calls": false,
|
|
}),
|
|
json!({
|
|
"responses_lite": "true",
|
|
"reasoning_context": "all_turns",
|
|
"parallel_tool_calls": false,
|
|
}),
|
|
json!({
|
|
"responses_lite": null,
|
|
"reasoning_context": null,
|
|
"parallel_tool_calls": false,
|
|
}),
|
|
]
|
|
);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_preconnect_is_reused_even_with_header_changes() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![vec![
|
|
ev_response_created("resp-1"),
|
|
ev_completed("resp-1"),
|
|
]]])
|
|
.await;
|
|
|
|
let harness = websocket_harness(&server).await;
|
|
let mut client_session = harness.client.new_session();
|
|
let preconnect_metadata = websocket_connection_metadata(&harness);
|
|
client_session
|
|
.preconnect_websocket(&harness.session_telemetry, &preconnect_metadata)
|
|
.await
|
|
.expect("websocket preconnect failed");
|
|
let prompt = prompt_with_input(vec![message_item("hello")]);
|
|
let responses_metadata = turn_metadata(&harness, /*turn_id*/ None);
|
|
let mut stream = client_session
|
|
.stream(
|
|
&prompt,
|
|
&harness.model_info,
|
|
&harness.session_telemetry,
|
|
harness.effort.clone(),
|
|
harness.summary,
|
|
/*service_tier*/ None,
|
|
&responses_metadata,
|
|
&codex_rollout_trace::InferenceTraceContext::disabled(),
|
|
)
|
|
.await
|
|
.expect("websocket stream failed");
|
|
|
|
while let Some(event) = stream.next().await {
|
|
if matches!(event, Ok(ResponseEvent::Completed { .. })) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
assert_eq!(server.handshakes().len(), 1);
|
|
assert_eq!(server.single_connection().len(), 1);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_request_prewarm_is_reused_even_with_header_changes() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![ev_response_created("warm-1"), ev_completed("warm-1")],
|
|
vec![ev_response_created("resp-1"), ev_completed("resp-1")],
|
|
]])
|
|
.await;
|
|
|
|
let harness = websocket_harness_with_options(&server, /*runtime_metrics_enabled*/ true).await;
|
|
let mut client_session = harness.client.new_session();
|
|
let prompt = prompt_with_input(vec![message_item("hello")]);
|
|
let prewarm_responses_metadata = prewarm_metadata(&harness, /*turn_id*/ None);
|
|
client_session
|
|
.prewarm_websocket(
|
|
&prompt,
|
|
&harness.model_info,
|
|
&harness.session_telemetry,
|
|
harness.effort.clone(),
|
|
harness.summary,
|
|
/*service_tier*/ None,
|
|
&prewarm_responses_metadata,
|
|
)
|
|
.await
|
|
.expect("websocket prewarm failed");
|
|
let responses_metadata = turn_metadata(&harness, /*turn_id*/ None);
|
|
let mut stream = client_session
|
|
.stream(
|
|
&prompt,
|
|
&harness.model_info,
|
|
&harness.session_telemetry,
|
|
harness.effort.clone(),
|
|
harness.summary,
|
|
/*service_tier*/ None,
|
|
&responses_metadata,
|
|
&codex_rollout_trace::InferenceTraceContext::disabled(),
|
|
)
|
|
.await
|
|
.expect("websocket stream failed");
|
|
|
|
while let Some(event) = stream.next().await {
|
|
if matches!(event, Ok(ResponseEvent::Completed { .. })) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
assert_eq!(server.handshakes().len(), 1);
|
|
let connection = server.single_connection();
|
|
assert_eq!(connection.len(), 2);
|
|
let warmup = connection
|
|
.first()
|
|
.expect("missing warmup request")
|
|
.body_json();
|
|
let follow_up = connection
|
|
.get(1)
|
|
.expect("missing follow-up request")
|
|
.body_json();
|
|
assert_eq!(warmup["type"].as_str(), Some("response.create"));
|
|
assert_eq!(warmup["generate"].as_bool(), Some(false));
|
|
assert_eq!(warmup["tools"], serde_json::json!([]));
|
|
assert_eq!(follow_up["type"].as_str(), Some("response.create"));
|
|
assert_eq!(follow_up["previous_response_id"].as_str(), Some("warm-1"));
|
|
assert_eq!(follow_up["input"], serde_json::json!([]));
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_prewarm_uses_v2_when_provider_supports_websockets() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![vec![
|
|
ev_response_created("resp-1"),
|
|
ev_completed("resp-1"),
|
|
]]])
|
|
.await;
|
|
|
|
let harness = websocket_harness_with_options(&server, /*runtime_metrics_enabled*/ false).await;
|
|
let mut client_session = harness.client.new_session();
|
|
let prompt = prompt_with_input(vec![message_item("hello")]);
|
|
let responses_metadata = prewarm_metadata(&harness, /*turn_id*/ None);
|
|
client_session
|
|
.prewarm_websocket(
|
|
&prompt,
|
|
&harness.model_info,
|
|
&harness.session_telemetry,
|
|
harness.effort.clone(),
|
|
harness.summary,
|
|
/*service_tier*/ None,
|
|
&responses_metadata,
|
|
)
|
|
.await
|
|
.expect("websocket prewarm failed");
|
|
|
|
// V2 prewarm issues a request on the websocket connection.
|
|
assert_eq!(server.handshakes().len(), 1);
|
|
assert_eq!(server.single_connection().len(), 1);
|
|
|
|
let handshake = server.single_handshake();
|
|
let openai_beta_header = handshake
|
|
.header(OPENAI_BETA_HEADER)
|
|
.expect("missing OpenAI-Beta header");
|
|
assert!(
|
|
openai_beta_header
|
|
.split(',')
|
|
.map(str::trim)
|
|
.any(|value| value == WS_V2_BETA_HEADER_VALUE)
|
|
);
|
|
stream_until_complete(&mut client_session, &harness, &prompt).await;
|
|
assert_eq!(server.handshakes().len(), 1);
|
|
let connection = server.single_connection();
|
|
assert_eq!(connection.len(), 1);
|
|
let prewarm = connection
|
|
.first()
|
|
.expect("missing prewarm request")
|
|
.body_json();
|
|
assert_eq!(prewarm["type"].as_str(), Some("response.create"));
|
|
assert_eq!(
|
|
prewarm["input"],
|
|
serde_json::to_value(&prompt.input).unwrap()
|
|
);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_preconnect_runs_when_only_v2_feature_enabled() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![vec![
|
|
ev_response_created("resp-1"),
|
|
ev_completed("resp-1"),
|
|
]]])
|
|
.await;
|
|
|
|
let harness = websocket_harness_with_options(&server, /*runtime_metrics_enabled*/ true).await;
|
|
let mut client_session = harness.client.new_session();
|
|
let responses_metadata = websocket_connection_metadata(&harness);
|
|
client_session
|
|
.preconnect_websocket(&harness.session_telemetry, &responses_metadata)
|
|
.await
|
|
.expect("websocket preconnect failed");
|
|
|
|
assert_eq!(server.handshakes().len(), 1);
|
|
assert_eq!(server.single_connection().len(), 0);
|
|
assert_eq!(
|
|
server.single_handshake().header("x-codex-turn-metadata"),
|
|
None
|
|
);
|
|
|
|
let prompt = prompt_with_input(vec![message_item("hello")]);
|
|
stream_until_complete(&mut client_session, &harness, &prompt).await;
|
|
|
|
assert_eq!(server.handshakes().len(), 1);
|
|
assert_eq!(server.single_connection().len(), 1);
|
|
|
|
let handshake = server.single_handshake();
|
|
let openai_beta_header = handshake
|
|
.header(OPENAI_BETA_HEADER)
|
|
.expect("missing OpenAI-Beta header");
|
|
assert!(
|
|
openai_beta_header
|
|
.split(',')
|
|
.map(str::trim)
|
|
.any(|value| value == WS_V2_BETA_HEADER_VALUE)
|
|
);
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_v2_requests_use_v2_when_provider_supports_websockets() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "assistant output"),
|
|
ev_completed("resp-1"),
|
|
],
|
|
vec![ev_response_created("resp-2"), ev_completed("resp-2")],
|
|
]])
|
|
.await;
|
|
|
|
let harness = websocket_harness_with_options(&server, /*runtime_metrics_enabled*/ true).await;
|
|
let mut client_session = harness.client.new_session();
|
|
let prompt_one = prompt_with_input(vec![message_item("hello")]);
|
|
let prompt_two = prompt_with_input(vec![
|
|
message_item("hello"),
|
|
assistant_message_item("msg-1", "assistant output"),
|
|
message_item("second"),
|
|
]);
|
|
|
|
stream_until_complete(&mut client_session, &harness, &prompt_one).await;
|
|
stream_until_complete(&mut client_session, &harness, &prompt_two).await;
|
|
|
|
let connection = server.single_connection();
|
|
assert_eq!(connection.len(), 2);
|
|
let second = connection.get(1).expect("missing request").body_json();
|
|
assert_eq!(second["type"].as_str(), Some("response.create"));
|
|
assert_eq!(second["previous_response_id"].as_str(), Some("resp-1"));
|
|
assert_eq!(
|
|
second["input"],
|
|
serde_json::to_value(&prompt_two.input[2..]).unwrap()
|
|
);
|
|
|
|
let handshake = server.single_handshake();
|
|
let openai_beta_header = handshake
|
|
.header(OPENAI_BETA_HEADER)
|
|
.expect("missing OpenAI-Beta header");
|
|
assert!(
|
|
openai_beta_header
|
|
.split(',')
|
|
.map(str::trim)
|
|
.any(|value| value == WS_V2_BETA_HEADER_VALUE)
|
|
);
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_v2_incremental_requests_are_reused_across_turns() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "assistant output"),
|
|
ev_completed("resp-1"),
|
|
],
|
|
vec![ev_response_created("resp-2"), ev_completed("resp-2")],
|
|
]])
|
|
.await;
|
|
|
|
let harness = websocket_harness_with_options(&server, /*runtime_metrics_enabled*/ false).await;
|
|
let prompt_one = prompt_with_input(vec![message_item("hello")]);
|
|
let prompt_two = prompt_with_input(vec![
|
|
message_item("hello"),
|
|
assistant_message_item("msg-1", "assistant output"),
|
|
message_item("second"),
|
|
]);
|
|
|
|
{
|
|
let mut client_session = harness.client.new_session();
|
|
stream_until_complete(&mut client_session, &harness, &prompt_one).await;
|
|
}
|
|
|
|
let mut client_session = harness.client.new_session();
|
|
stream_until_complete(&mut client_session, &harness, &prompt_two).await;
|
|
|
|
assert_eq!(server.handshakes().len(), 1);
|
|
let connection = server.single_connection();
|
|
assert_eq!(connection.len(), 2);
|
|
let second = connection.get(1).expect("missing request").body_json();
|
|
assert_eq!(second["type"].as_str(), Some("response.create"));
|
|
assert_eq!(second["previous_response_id"].as_str(), Some("resp-1"));
|
|
assert_eq!(
|
|
second["input"],
|
|
serde_json::to_value(&prompt_two.input[2..]).unwrap()
|
|
);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_v2_wins_when_both_features_enabled() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "assistant output"),
|
|
ev_completed("resp-1"),
|
|
],
|
|
vec![ev_response_created("resp-2"), ev_completed("resp-2")],
|
|
]])
|
|
.await;
|
|
|
|
let harness = websocket_harness_with_options(&server, /*runtime_metrics_enabled*/ false).await;
|
|
let mut client_session = harness.client.new_session();
|
|
let prompt_one = prompt_with_input(vec![message_item("hello")]);
|
|
let prompt_two = prompt_with_input(vec![
|
|
message_item("hello"),
|
|
assistant_message_item("msg-1", "assistant output"),
|
|
message_item("second"),
|
|
]);
|
|
|
|
stream_until_complete(&mut client_session, &harness, &prompt_one).await;
|
|
stream_until_complete(&mut client_session, &harness, &prompt_two).await;
|
|
|
|
let connection = server.single_connection();
|
|
assert_eq!(connection.len(), 2);
|
|
let second = connection.get(1).expect("missing request").body_json();
|
|
assert_eq!(second["type"].as_str(), Some("response.create"));
|
|
assert_eq!(second["previous_response_id"].as_str(), Some("resp-1"));
|
|
assert_eq!(
|
|
second["input"],
|
|
serde_json::to_value(&prompt_two.input[2..]).unwrap()
|
|
);
|
|
|
|
let handshake = server.single_handshake();
|
|
let openai_beta_header = handshake
|
|
.header(OPENAI_BETA_HEADER)
|
|
.expect("missing OpenAI-Beta header");
|
|
assert!(
|
|
openai_beta_header
|
|
.split(',')
|
|
.map(str::trim)
|
|
.any(|value| value == WS_V2_BETA_HEADER_VALUE)
|
|
);
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
#[traced_test]
|
|
async fn responses_websocket_emits_websocket_telemetry_events() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![vec![
|
|
ev_response_created("resp-1"),
|
|
ev_completed("resp-1"),
|
|
]]])
|
|
.await;
|
|
|
|
let harness = websocket_harness(&server).await;
|
|
harness.session_telemetry.reset_runtime_metrics();
|
|
let mut client_session = harness.client.new_session();
|
|
let prompt = prompt_with_input(vec![message_item("hello")]);
|
|
|
|
stream_until_complete(&mut client_session, &harness, &prompt).await;
|
|
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
|
|
let summary = harness
|
|
.session_telemetry
|
|
.runtime_metrics_summary()
|
|
.expect("runtime metrics summary");
|
|
assert_eq!(summary.api_calls.count, 0);
|
|
assert_eq!(summary.streaming_events.count, 0);
|
|
assert_eq!(summary.websocket_calls.count, 1);
|
|
assert_eq!(summary.websocket_events.count, 2);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_includes_timing_metrics_header_when_runtime_metrics_enabled() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![vec![
|
|
ev_response_created("resp-1"),
|
|
serde_json::json!({
|
|
"type": "responsesapi.websocket_timing",
|
|
"timing_metrics": {
|
|
"responses_duration_excl_engine_and_client_tool_time_ms": 120,
|
|
"engine_service_total_ms": 450,
|
|
"engine_iapi_ttft_total_ms": 310,
|
|
"engine_service_ttft_total_ms": 340,
|
|
"engine_iapi_tbt_across_engine_calls_ms": 220,
|
|
"engine_service_tbt_across_engine_calls_ms": 260
|
|
}
|
|
}),
|
|
ev_completed("resp-1"),
|
|
]]])
|
|
.await;
|
|
|
|
let harness =
|
|
websocket_harness_with_runtime_metrics(&server, /*runtime_metrics_enabled*/ true).await;
|
|
harness.session_telemetry.reset_runtime_metrics();
|
|
let mut client_session = harness.client.new_session();
|
|
let prompt = prompt_with_input(vec![message_item("hello")]);
|
|
|
|
stream_until_complete(&mut client_session, &harness, &prompt).await;
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
|
|
let handshake = server.single_handshake();
|
|
assert_eq!(
|
|
handshake.header(X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER),
|
|
Some("true".to_string())
|
|
);
|
|
|
|
let summary = harness
|
|
.session_telemetry
|
|
.runtime_metrics_summary()
|
|
.expect("runtime metrics summary");
|
|
assert_eq!(summary.responses_api_overhead_ms, 120);
|
|
assert_eq!(summary.responses_api_inference_time_ms, 450);
|
|
assert_eq!(summary.responses_api_engine_iapi_ttft_ms, 310);
|
|
assert_eq!(summary.responses_api_engine_service_ttft_ms, 340);
|
|
assert_eq!(summary.responses_api_engine_iapi_tbt_ms, 220);
|
|
assert_eq!(summary.responses_api_engine_service_tbt_ms, 260);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_omits_timing_metrics_header_when_runtime_metrics_disabled() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![vec![
|
|
ev_response_created("resp-1"),
|
|
ev_completed("resp-1"),
|
|
]]])
|
|
.await;
|
|
|
|
let harness =
|
|
websocket_harness_with_runtime_metrics(&server, /*runtime_metrics_enabled*/ false).await;
|
|
let mut client_session = harness.client.new_session();
|
|
let prompt = prompt_with_input(vec![message_item("hello")]);
|
|
|
|
stream_until_complete(&mut client_session, &harness, &prompt).await;
|
|
|
|
let handshake = server.single_handshake();
|
|
assert_eq!(
|
|
handshake.header(X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER),
|
|
None
|
|
);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_emits_reasoning_included_event() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server_with_headers(vec![WebSocketConnectionConfig {
|
|
requests: vec![vec![ev_response_created("resp-1"), ev_completed("resp-1")]],
|
|
response_headers: vec![("X-Reasoning-Included".to_string(), "true".to_string())],
|
|
accept_delay: None,
|
|
close_after_requests: true,
|
|
}])
|
|
.await;
|
|
|
|
let harness = websocket_harness(&server).await;
|
|
let mut client_session = harness.client.new_session();
|
|
let prompt = prompt_with_input(vec![message_item("hello")]);
|
|
let responses_metadata = turn_metadata(&harness, /*turn_id*/ None);
|
|
|
|
let mut stream = client_session
|
|
.stream(
|
|
&prompt,
|
|
&harness.model_info,
|
|
&harness.session_telemetry,
|
|
harness.effort.clone(),
|
|
harness.summary,
|
|
/*service_tier*/ None,
|
|
&responses_metadata,
|
|
&codex_rollout_trace::InferenceTraceContext::disabled(),
|
|
)
|
|
.await
|
|
.expect("websocket stream failed");
|
|
|
|
let mut saw_reasoning_included = false;
|
|
while let Some(event) = stream.next().await {
|
|
match event.expect("event") {
|
|
ResponseEvent::ServerReasoningIncluded(true) => {
|
|
saw_reasoning_included = true;
|
|
}
|
|
ResponseEvent::Completed { .. } => break,
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
assert!(saw_reasoning_included);
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_emits_rate_limit_events() {
|
|
skip_if_no_network!();
|
|
|
|
let rate_limit_event = json!({
|
|
"type": "codex.rate_limits",
|
|
"plan_type": "plus",
|
|
"rate_limits": {
|
|
"allowed": true,
|
|
"limit_reached": false,
|
|
"primary": {
|
|
"used_percent": 42,
|
|
"window_minutes": 60,
|
|
"reset_at": 1700000000
|
|
},
|
|
"secondary": null
|
|
},
|
|
"code_review_rate_limits": null,
|
|
"credits": {
|
|
"has_credits": true,
|
|
"unlimited": false,
|
|
"balance": "123"
|
|
},
|
|
"promo": null
|
|
});
|
|
|
|
let server = start_websocket_server_with_headers(vec![WebSocketConnectionConfig {
|
|
requests: vec![vec![
|
|
rate_limit_event,
|
|
ev_response_created("resp-1"),
|
|
ev_completed("resp-1"),
|
|
]],
|
|
response_headers: vec![
|
|
("X-Models-Etag".to_string(), "etag-123".to_string()),
|
|
("X-Reasoning-Included".to_string(), "true".to_string()),
|
|
],
|
|
accept_delay: None,
|
|
close_after_requests: true,
|
|
}])
|
|
.await;
|
|
|
|
let harness = websocket_harness(&server).await;
|
|
let mut client_session = harness.client.new_session();
|
|
let prompt = prompt_with_input(vec![message_item("hello")]);
|
|
let responses_metadata = turn_metadata(&harness, /*turn_id*/ None);
|
|
|
|
let mut stream = client_session
|
|
.stream(
|
|
&prompt,
|
|
&harness.model_info,
|
|
&harness.session_telemetry,
|
|
harness.effort.clone(),
|
|
harness.summary,
|
|
/*service_tier*/ None,
|
|
&responses_metadata,
|
|
&codex_rollout_trace::InferenceTraceContext::disabled(),
|
|
)
|
|
.await
|
|
.expect("websocket stream failed");
|
|
|
|
let mut saw_rate_limits = None;
|
|
let mut saw_models_etag = None;
|
|
let mut saw_reasoning_included = false;
|
|
|
|
while let Some(event) = stream.next().await {
|
|
match event.expect("event") {
|
|
ResponseEvent::RateLimits(snapshot) => {
|
|
saw_rate_limits = Some(snapshot);
|
|
}
|
|
ResponseEvent::ModelsEtag(etag) => {
|
|
saw_models_etag = Some(etag);
|
|
}
|
|
ResponseEvent::ServerReasoningIncluded(true) => {
|
|
saw_reasoning_included = true;
|
|
}
|
|
ResponseEvent::Completed { .. } => break,
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
let rate_limits = saw_rate_limits.expect("missing rate limits");
|
|
let primary = rate_limits.primary.expect("missing primary window");
|
|
assert_eq!(primary.used_percent, 42.0);
|
|
assert_eq!(primary.window_minutes, Some(60));
|
|
assert_eq!(primary.resets_at, Some(1_700_000_000));
|
|
assert_eq!(rate_limits.plan_type, Some(PlanType::Plus));
|
|
let credits = rate_limits.credits.expect("missing credits");
|
|
assert!(credits.has_credits);
|
|
assert!(!credits.unlimited);
|
|
assert_eq!(credits.balance.as_deref(), Some("123"));
|
|
assert_eq!(saw_models_etag.as_deref(), Some("etag-123"));
|
|
assert!(saw_reasoning_included);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_usage_limit_error_emits_rate_limit_event() {
|
|
skip_if_no_network!();
|
|
|
|
let usage_limit_error = json!({
|
|
"type": "error",
|
|
"status": 429,
|
|
"error": {
|
|
"type": "usage_limit_reached",
|
|
"message": "The usage limit has been reached",
|
|
"plan_type": "pro",
|
|
"resets_at": 1704067242,
|
|
"resets_in_seconds": 1234
|
|
},
|
|
"headers": {
|
|
"x-codex-primary-used-percent": "100.0",
|
|
"x-codex-secondary-used-percent": "87.5",
|
|
"x-codex-primary-over-secondary-limit-percent": "95.0",
|
|
"x-codex-primary-window-minutes": "15",
|
|
"x-codex-secondary-window-minutes": "60"
|
|
}
|
|
});
|
|
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![
|
|
ev_response_created("resp-prewarm"),
|
|
ev_completed("resp-prewarm"),
|
|
],
|
|
vec![usage_limit_error],
|
|
]])
|
|
.await;
|
|
let mut builder = test_codex().with_config(|config| {
|
|
config.model_provider.request_max_retries = Some(0);
|
|
config.model_provider.stream_max_retries = Some(0);
|
|
});
|
|
let test = builder
|
|
.build_with_websocket_server(&server)
|
|
.await
|
|
.expect("build websocket codex");
|
|
|
|
let submission_id = test
|
|
.codex
|
|
.submit(Op::UserInput {
|
|
items: vec![UserInput::Text {
|
|
text: "hello".into(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
final_output_json_schema: None,
|
|
responsesapi_client_metadata: None,
|
|
additional_context: Default::default(),
|
|
thread_settings: Default::default(),
|
|
})
|
|
.await
|
|
.expect("submission should succeed while emitting usage limit error events");
|
|
|
|
let token_event =
|
|
wait_for_event(&test.codex, |msg| matches!(msg, EventMsg::TokenCount(_))).await;
|
|
let EventMsg::TokenCount(event) = token_event else {
|
|
unreachable!();
|
|
};
|
|
|
|
let event_json = serde_json::to_value(&event).expect("serialize token count event");
|
|
pretty_assertions::assert_eq!(
|
|
event_json,
|
|
json!({
|
|
"info": null,
|
|
"rate_limits": {
|
|
"limit_id": "codex",
|
|
"limit_name": null,
|
|
"primary": {
|
|
"used_percent": 100.0,
|
|
"window_minutes": 15,
|
|
"resets_at": null
|
|
},
|
|
"secondary": {
|
|
"used_percent": 87.5,
|
|
"window_minutes": 60,
|
|
"resets_at": null
|
|
},
|
|
"credits": null,
|
|
"individual_limit": null,
|
|
"plan_type": null,
|
|
"rate_limit_reached_type": null
|
|
}
|
|
})
|
|
);
|
|
|
|
let error_event = wait_for_event(&test.codex, |msg| matches!(msg, EventMsg::Error(_))).await;
|
|
let EventMsg::Error(error_event) = error_event else {
|
|
unreachable!();
|
|
};
|
|
assert!(
|
|
error_event.message.to_lowercase().contains("usage limit"),
|
|
"unexpected error message for submission {submission_id}: {}",
|
|
error_event.message
|
|
);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_invalid_request_error_with_status_is_forwarded() {
|
|
skip_if_no_network!();
|
|
|
|
let invalid_request_error = json!({
|
|
"type": "error",
|
|
"status": 400,
|
|
"error": {
|
|
"type": "invalid_request_error",
|
|
"message": "Model 'castor-raikou-0205-ev3' does not support image inputs."
|
|
}
|
|
});
|
|
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![
|
|
ev_response_created("resp-prewarm"),
|
|
ev_completed("resp-prewarm"),
|
|
],
|
|
vec![invalid_request_error],
|
|
]])
|
|
.await;
|
|
let mut builder = test_codex().with_config(|config| {
|
|
config.model_provider.request_max_retries = Some(0);
|
|
config.model_provider.stream_max_retries = Some(0);
|
|
});
|
|
let test = builder
|
|
.build_with_websocket_server(&server)
|
|
.await
|
|
.expect("build websocket codex");
|
|
|
|
let submission_id = test
|
|
.codex
|
|
.submit(Op::UserInput {
|
|
items: vec![UserInput::Text {
|
|
text: "hello".into(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
final_output_json_schema: None,
|
|
responsesapi_client_metadata: None,
|
|
additional_context: Default::default(),
|
|
thread_settings: Default::default(),
|
|
})
|
|
.await
|
|
.expect("submission should succeed while emitting invalid request events");
|
|
|
|
let error_event = wait_for_event(&test.codex, |msg| matches!(msg, EventMsg::Error(_))).await;
|
|
let EventMsg::Error(error_event) = error_event else {
|
|
unreachable!();
|
|
};
|
|
assert!(
|
|
error_event
|
|
.message
|
|
.to_lowercase()
|
|
.contains("does not support image inputs"),
|
|
"unexpected error message for submission {submission_id}: {}",
|
|
error_event.message
|
|
);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_connection_limit_error_reconnects_and_completes() {
|
|
skip_if_no_network!();
|
|
|
|
let websocket_connection_limit_error = 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."
|
|
}
|
|
});
|
|
|
|
let server = start_websocket_server(vec![
|
|
vec![vec![websocket_connection_limit_error]],
|
|
vec![vec![ev_response_created("resp-1"), ev_completed("resp-1")]],
|
|
])
|
|
.await;
|
|
let mut builder = test_codex().with_config(|config| {
|
|
config.model_provider.request_max_retries = Some(0);
|
|
config.model_provider.stream_max_retries = Some(1);
|
|
});
|
|
let test = builder
|
|
.build_with_websocket_server(&server)
|
|
.await
|
|
.expect("build websocket codex");
|
|
|
|
test.submit_turn("hello")
|
|
.await
|
|
.expect("submission should reconnect after websocket connection limit error");
|
|
|
|
let total_websocket_requests: usize = server.connections().iter().map(Vec::len).sum();
|
|
assert_eq!(total_websocket_requests, 2);
|
|
let handshake_user_agents: Vec<_> = server
|
|
.handshakes()
|
|
.iter()
|
|
.map(|handshake| handshake.header(USER_AGENT_HEADER))
|
|
.collect();
|
|
assert_eq!(
|
|
handshake_user_agents,
|
|
vec![
|
|
Some(codex_login::default_client::get_codex_user_agent()),
|
|
Some(codex_login::default_client::get_codex_user_agent()),
|
|
]
|
|
);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_uses_incremental_create_on_prefix() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "assistant output"),
|
|
ev_completed("resp-1"),
|
|
],
|
|
vec![ev_response_created("resp-2"), ev_completed("resp-2")],
|
|
]])
|
|
.await;
|
|
|
|
let harness = websocket_harness(&server).await;
|
|
let mut client_session = harness.client.new_session();
|
|
let prompt_one = prompt_with_input(vec![message_item("hello")]);
|
|
let prompt_two = prompt_with_input(vec![
|
|
message_item("hello"),
|
|
assistant_message_item("msg-1", "assistant output"),
|
|
message_item("second"),
|
|
]);
|
|
|
|
stream_until_complete(&mut client_session, &harness, &prompt_one).await;
|
|
stream_until_complete(&mut client_session, &harness, &prompt_two).await;
|
|
|
|
let connection = server.single_connection();
|
|
assert_eq!(connection.len(), 2);
|
|
let first = connection.first().expect("missing request").body_json();
|
|
let second = connection.get(1).expect("missing request").body_json();
|
|
|
|
assert_eq!(first["type"].as_str(), Some("response.create"));
|
|
assert_eq!(first["model"].as_str(), Some(MODEL));
|
|
assert_eq!(first["stream"], serde_json::Value::Bool(true));
|
|
assert_eq!(first["input"].as_array().map(Vec::len), Some(1));
|
|
assert_eq!(second["type"].as_str(), Some("response.create"));
|
|
assert_eq!(second["previous_response_id"].as_str(), Some("resp-1"));
|
|
assert_eq!(
|
|
second["input"],
|
|
serde_json::to_value(&prompt_two.input[2..]).expect("serialize incremental items")
|
|
);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_forwards_turn_metadata_on_initial_and_incremental_create() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "assistant output"),
|
|
ev_completed("resp-1"),
|
|
],
|
|
vec![ev_response_created("resp-2"), ev_completed("resp-2")],
|
|
]])
|
|
.await;
|
|
|
|
let harness = websocket_harness(&server).await;
|
|
let mut client_session = harness.client.new_session();
|
|
let prompt_one = prompt_with_input(vec![message_item("hello")]);
|
|
let prompt_two = prompt_with_input(vec![
|
|
message_item("hello"),
|
|
assistant_message_item("msg-1", "assistant output"),
|
|
message_item("second"),
|
|
]);
|
|
let first_responses_metadata = turn_metadata(&harness, Some("turn-123"));
|
|
let second_responses_metadata = turn_metadata(&harness, Some("turn-456"));
|
|
|
|
stream_until_complete_with_metadata(
|
|
&mut client_session,
|
|
&harness,
|
|
&prompt_one,
|
|
/*service_tier*/ None,
|
|
&first_responses_metadata,
|
|
)
|
|
.await;
|
|
stream_until_complete_with_metadata(
|
|
&mut client_session,
|
|
&harness,
|
|
&prompt_two,
|
|
/*service_tier*/ None,
|
|
&second_responses_metadata,
|
|
)
|
|
.await;
|
|
|
|
let connection = server.single_connection();
|
|
assert_eq!(connection.len(), 2);
|
|
let first = connection.first().expect("missing request").body_json();
|
|
let second = connection.get(1).expect("missing request").body_json();
|
|
|
|
assert_eq!(first["type"].as_str(), Some("response.create"));
|
|
assert_eq!(second["type"].as_str(), Some("response.create"));
|
|
assert_eq!(second["previous_response_id"].as_str(), Some("resp-1"));
|
|
let first_metadata: serde_json::Value = serde_json::from_str(
|
|
first["client_metadata"]["x-codex-turn-metadata"]
|
|
.as_str()
|
|
.expect("first turn metadata"),
|
|
)
|
|
.expect("first metadata should be valid json");
|
|
let second_metadata: serde_json::Value = serde_json::from_str(
|
|
second["client_metadata"]["x-codex-turn-metadata"]
|
|
.as_str()
|
|
.expect("second turn metadata"),
|
|
)
|
|
.expect("second metadata should be valid json");
|
|
|
|
assert_eq!(first_metadata["turn_id"].as_str(), Some("turn-123"));
|
|
assert_eq!(second_metadata["turn_id"].as_str(), Some("turn-456"));
|
|
assert_eq!(
|
|
first["client_metadata"]["turn_id"].as_str(),
|
|
first_metadata["turn_id"].as_str()
|
|
);
|
|
assert_eq!(
|
|
second["client_metadata"]["turn_id"].as_str(),
|
|
second_metadata["turn_id"].as_str()
|
|
);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_sends_canonical_turn_metadata() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![vec![
|
|
ev_response_created("resp-1"),
|
|
ev_completed("resp-1"),
|
|
]]])
|
|
.await;
|
|
|
|
let harness = websocket_harness(&server).await;
|
|
let mut client_session = harness.client.new_session();
|
|
let prompt = prompt_with_input(vec![message_item("hello")]);
|
|
let responses_metadata = turn_metadata(&harness, Some("turn-123"));
|
|
|
|
stream_until_complete_with_metadata(
|
|
&mut client_session,
|
|
&harness,
|
|
&prompt,
|
|
/*service_tier*/ None,
|
|
&responses_metadata,
|
|
)
|
|
.await;
|
|
|
|
let body = server
|
|
.single_connection()
|
|
.first()
|
|
.expect("missing request")
|
|
.body_json();
|
|
|
|
assert_eq!(body["type"].as_str(), Some("response.create"));
|
|
let turn_metadata: serde_json::Value = serde_json::from_str(
|
|
body["client_metadata"]["x-codex-turn-metadata"]
|
|
.as_str()
|
|
.expect("turn metadata"),
|
|
)
|
|
.expect("valid turn metadata");
|
|
assert_eq!(turn_metadata["turn_id"].as_str(), Some("turn-123"));
|
|
assert_eq!(
|
|
body["client_metadata"]["turn_id"].as_str(),
|
|
turn_metadata["turn_id"].as_str()
|
|
);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_uses_previous_response_id_when_prefix_after_completed() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "assistant output"),
|
|
ev_completed("resp-1"),
|
|
],
|
|
vec![ev_response_created("resp-2"), ev_completed("resp-2")],
|
|
]])
|
|
.await;
|
|
|
|
let harness = websocket_harness(&server).await;
|
|
let mut client_session = harness.client.new_session();
|
|
let prompt_one = prompt_with_input(vec![message_item("hello")]);
|
|
let prompt_two = prompt_with_input(vec![
|
|
message_item("hello"),
|
|
assistant_message_item("msg-1", "assistant output"),
|
|
message_item("second"),
|
|
]);
|
|
|
|
stream_until_complete(&mut client_session, &harness, &prompt_one).await;
|
|
stream_until_complete(&mut client_session, &harness, &prompt_two).await;
|
|
|
|
let connection = server.single_connection();
|
|
assert_eq!(connection.len(), 2);
|
|
let second = connection.get(1).expect("missing request").body_json();
|
|
|
|
assert_eq!(second["type"].as_str(), Some("response.create"));
|
|
assert_eq!(second["previous_response_id"].as_str(), Some("resp-1"));
|
|
assert_eq!(
|
|
second["input"],
|
|
serde_json::to_value(&prompt_two.input[2..]).expect("serialize incremental input")
|
|
);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_creates_on_non_prefix() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![ev_response_created("resp-1"), ev_completed("resp-1")],
|
|
vec![ev_response_created("resp-2"), ev_completed("resp-2")],
|
|
]])
|
|
.await;
|
|
|
|
let harness = websocket_harness(&server).await;
|
|
let mut client_session = harness.client.new_session();
|
|
let prompt_one = prompt_with_input(vec![message_item("hello")]);
|
|
let prompt_two = prompt_with_input(vec![message_item("different")]);
|
|
|
|
stream_until_complete(&mut client_session, &harness, &prompt_one).await;
|
|
stream_until_complete(&mut client_session, &harness, &prompt_two).await;
|
|
|
|
let connection = server.single_connection();
|
|
assert_eq!(connection.len(), 2);
|
|
let second = connection.get(1).expect("missing request").body_json();
|
|
|
|
assert_eq!(second["type"].as_str(), Some("response.create"));
|
|
assert_eq!(second["model"].as_str(), Some(MODEL));
|
|
assert_eq!(second["stream"], serde_json::Value::Bool(true));
|
|
assert_eq!(
|
|
second["input"],
|
|
serde_json::to_value(&prompt_two.input).unwrap()
|
|
);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_creates_when_non_input_request_fields_change() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![ev_response_created("resp-1"), ev_completed("resp-1")],
|
|
vec![ev_response_created("resp-2"), ev_completed("resp-2")],
|
|
]])
|
|
.await;
|
|
|
|
let harness = websocket_harness(&server).await;
|
|
let mut client_session = harness.client.new_session();
|
|
let prompt_one =
|
|
prompt_with_input_and_instructions(vec![message_item("hello")], "base instructions one");
|
|
let prompt_two = prompt_with_input_and_instructions(
|
|
vec![message_item("hello"), message_item("second")],
|
|
"base instructions two",
|
|
);
|
|
|
|
stream_until_complete(&mut client_session, &harness, &prompt_one).await;
|
|
stream_until_complete(&mut client_session, &harness, &prompt_two).await;
|
|
|
|
let connection = server.single_connection();
|
|
assert_eq!(connection.len(), 2);
|
|
let second = connection.get(1).expect("missing request").body_json();
|
|
|
|
assert_eq!(second["type"].as_str(), Some("response.create"));
|
|
assert_eq!(second.get("previous_response_id"), None);
|
|
assert_eq!(
|
|
second["input"],
|
|
serde_json::to_value(&prompt_two.input).expect("serialize full input")
|
|
);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_v2_creates_with_previous_response_id_on_prefix() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "assistant output"),
|
|
ev_completed("resp-1"),
|
|
],
|
|
vec![ev_response_created("resp-2"), ev_completed("resp-2")],
|
|
]])
|
|
.await;
|
|
|
|
let harness = websocket_harness_with_v2(&server, /*runtime_metrics_enabled*/ true).await;
|
|
let mut session = harness.client.new_session();
|
|
let prompt_one = prompt_with_input(vec![message_item("hello")]);
|
|
let prompt_two = prompt_with_input(vec![
|
|
message_item("hello"),
|
|
assistant_message_item("msg-1", "assistant output"),
|
|
message_item("second"),
|
|
]);
|
|
|
|
stream_until_complete(&mut session, &harness, &prompt_one).await;
|
|
stream_until_complete(&mut session, &harness, &prompt_two).await;
|
|
|
|
let connection = server.single_connection();
|
|
assert_eq!(connection.len(), 2);
|
|
let first = connection.first().expect("missing request").body_json();
|
|
let second = connection.get(1).expect("missing request").body_json();
|
|
|
|
assert_eq!(first["type"].as_str(), Some("response.create"));
|
|
assert_eq!(second["type"].as_str(), Some("response.create"));
|
|
assert_eq!(second["previous_response_id"].as_str(), Some("resp-1"));
|
|
assert_eq!(
|
|
second["input"],
|
|
serde_json::to_value(&prompt_two.input[2..]).unwrap()
|
|
);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_v2_creates_without_previous_response_id_when_non_input_fields_change()
|
|
{
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![
|
|
vec![ev_response_created("resp-1"), ev_completed("resp-1")],
|
|
vec![ev_response_created("resp-2"), ev_completed("resp-2")],
|
|
]])
|
|
.await;
|
|
|
|
let harness = websocket_harness_with_v2(&server, /*runtime_metrics_enabled*/ true).await;
|
|
let mut session = harness.client.new_session();
|
|
let prompt_one =
|
|
prompt_with_input_and_instructions(vec![message_item("hello")], "base instructions one");
|
|
let prompt_two = prompt_with_input_and_instructions(
|
|
vec![message_item("hello"), message_item("second")],
|
|
"base instructions two",
|
|
);
|
|
|
|
stream_until_complete(&mut session, &harness, &prompt_one).await;
|
|
stream_until_complete(&mut session, &harness, &prompt_two).await;
|
|
|
|
let connection = server.single_connection();
|
|
assert_eq!(connection.len(), 2);
|
|
let second = connection.get(1).expect("missing request").body_json();
|
|
|
|
assert_eq!(second["type"].as_str(), Some("response.create"));
|
|
assert_eq!(second.get("previous_response_id"), None);
|
|
assert_eq!(
|
|
second["input"],
|
|
serde_json::to_value(&prompt_two.input).expect("serialize full input")
|
|
);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_v2_after_error_uses_full_create_without_previous_response_id() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![
|
|
vec![
|
|
vec![ev_response_created("resp-1"), ev_completed("resp-1")],
|
|
vec![json!({
|
|
"type": "response.failed",
|
|
"response": {
|
|
"error": {
|
|
"code": "invalid_prompt",
|
|
"message": "synthetic websocket failure"
|
|
}
|
|
}
|
|
})],
|
|
],
|
|
vec![vec![ev_response_created("resp-3"), ev_completed("resp-3")]],
|
|
])
|
|
.await;
|
|
|
|
let harness = websocket_harness_with_v2(&server, /*runtime_metrics_enabled*/ true).await;
|
|
let mut session = harness.client.new_session();
|
|
let prompt_one = prompt_with_input(vec![message_item("hello")]);
|
|
let prompt_two = prompt_with_input(vec![message_item("hello"), message_item("second")]);
|
|
let prompt_three = prompt_with_input(vec![
|
|
message_item("hello"),
|
|
message_item("second"),
|
|
message_item("third"),
|
|
]);
|
|
|
|
stream_until_complete(&mut session, &harness, &prompt_one).await;
|
|
|
|
let responses_metadata = turn_metadata(&harness, /*turn_id*/ None);
|
|
let mut second_stream = session
|
|
.stream(
|
|
&prompt_two,
|
|
&harness.model_info,
|
|
&harness.session_telemetry,
|
|
harness.effort.clone(),
|
|
harness.summary,
|
|
/*service_tier*/ None,
|
|
&responses_metadata,
|
|
&codex_rollout_trace::InferenceTraceContext::disabled(),
|
|
)
|
|
.await
|
|
.expect("websocket stream failed");
|
|
let mut saw_error = false;
|
|
while let Some(event) = second_stream.next().await {
|
|
if event.is_err() {
|
|
saw_error = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(saw_error, "expected second websocket stream to error");
|
|
|
|
stream_until_complete(&mut session, &harness, &prompt_three).await;
|
|
|
|
assert_eq!(server.handshakes().len(), 2);
|
|
|
|
let connections = server.connections();
|
|
assert_eq!(connections.len(), 2);
|
|
let first_connection = connections.first().expect("missing first connection");
|
|
assert_eq!(first_connection.len(), 2);
|
|
|
|
let first = first_connection
|
|
.first()
|
|
.expect("missing first request")
|
|
.body_json();
|
|
let second = first_connection
|
|
.get(1)
|
|
.expect("missing second request")
|
|
.body_json();
|
|
let third = connections
|
|
.get(1)
|
|
.and_then(|connection| connection.first())
|
|
.expect("missing third request")
|
|
.body_json();
|
|
|
|
assert_eq!(first["type"].as_str(), Some("response.create"));
|
|
assert_eq!(second["type"].as_str(), Some("response.create"));
|
|
assert_eq!(second["previous_response_id"].as_str(), Some("resp-1"));
|
|
assert_eq!(third["type"].as_str(), Some("response.create"));
|
|
assert_eq!(third.get("previous_response_id"), None);
|
|
assert_eq!(
|
|
third["input"],
|
|
serde_json::to_value(&prompt_three.input).unwrap()
|
|
);
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_v2_surfaces_terminal_error_without_close_handshake() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server_with_headers(vec![WebSocketConnectionConfig {
|
|
requests: vec![
|
|
vec![ev_response_created("resp-1"), ev_completed("resp-1")],
|
|
vec![json!({
|
|
"type": "response.failed",
|
|
"response": {
|
|
"error": {
|
|
"code": "invalid_prompt",
|
|
"message": "synthetic websocket failure"
|
|
}
|
|
}
|
|
})],
|
|
],
|
|
response_headers: Vec::new(),
|
|
accept_delay: None,
|
|
close_after_requests: false,
|
|
}])
|
|
.await;
|
|
|
|
let harness = websocket_harness_with_v2(&server, /*runtime_metrics_enabled*/ true).await;
|
|
let mut session = harness.client.new_session();
|
|
let prompt_one = prompt_with_input(vec![message_item("hello")]);
|
|
let prompt_two = prompt_with_input(vec![message_item("hello"), message_item("second")]);
|
|
|
|
stream_until_complete(&mut session, &harness, &prompt_one).await;
|
|
|
|
let responses_metadata = turn_metadata(&harness, /*turn_id*/ None);
|
|
let mut second_stream = session
|
|
.stream(
|
|
&prompt_two,
|
|
&harness.model_info,
|
|
&harness.session_telemetry,
|
|
harness.effort.clone(),
|
|
harness.summary,
|
|
/*service_tier*/ None,
|
|
&responses_metadata,
|
|
&codex_rollout_trace::InferenceTraceContext::disabled(),
|
|
)
|
|
.await
|
|
.expect("websocket stream failed");
|
|
|
|
let saw_error = tokio::time::timeout(Duration::from_secs(2), async {
|
|
while let Some(event) = second_stream.next().await {
|
|
if event.is_err() {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
})
|
|
.await
|
|
.expect("timed out waiting for terminal websocket error");
|
|
|
|
assert!(saw_error, "expected second websocket stream to error");
|
|
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn responses_websocket_v2_sets_openai_beta_header() {
|
|
skip_if_no_network!();
|
|
|
|
let server = start_websocket_server(vec![vec![vec![
|
|
ev_response_created("resp-1"),
|
|
ev_completed("resp-1"),
|
|
]]])
|
|
.await;
|
|
|
|
let harness = websocket_harness_with_v2(&server, /*runtime_metrics_enabled*/ true).await;
|
|
let mut session = harness.client.new_session();
|
|
let prompt = prompt_with_input(vec![message_item("hello")]);
|
|
|
|
stream_until_complete(&mut session, &harness, &prompt).await;
|
|
|
|
let handshake = server.single_handshake();
|
|
let openai_beta_header = handshake
|
|
.header(OPENAI_BETA_HEADER)
|
|
.expect("missing OpenAI-Beta header");
|
|
assert!(
|
|
openai_beta_header
|
|
.split(',')
|
|
.map(str::trim)
|
|
.any(|value| value == WS_V2_BETA_HEADER_VALUE)
|
|
);
|
|
server.shutdown().await;
|
|
}
|
|
|
|
fn message_item(text: &str) -> ResponseItem {
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: "user".into(),
|
|
content: vec![ContentItem::InputText { text: text.into() }],
|
|
phase: None,
|
|
}
|
|
}
|
|
|
|
fn assistant_message_item(id: &str, text: &str) -> ResponseItem {
|
|
ResponseItem::Message {
|
|
id: Some(id.to_string()),
|
|
role: "assistant".into(),
|
|
content: vec![ContentItem::OutputText { text: text.into() }],
|
|
phase: None,
|
|
}
|
|
}
|
|
|
|
fn prompt_with_input(input: Vec<ResponseItem>) -> Prompt {
|
|
let mut prompt = Prompt::default();
|
|
prompt.input = input;
|
|
prompt
|
|
}
|
|
|
|
fn prompt_with_input_and_instructions(input: Vec<ResponseItem>, instructions: &str) -> Prompt {
|
|
let mut prompt = prompt_with_input(input);
|
|
prompt.base_instructions = BaseInstructions {
|
|
text: instructions.to_string(),
|
|
};
|
|
prompt
|
|
}
|
|
|
|
fn websocket_provider(server: &WebSocketTestServer) -> ModelProviderInfo {
|
|
websocket_provider_with_connect_timeout(server, /*websocket_connect_timeout_ms*/ None)
|
|
}
|
|
|
|
fn websocket_provider_with_connect_timeout(
|
|
server: &WebSocketTestServer,
|
|
websocket_connect_timeout_ms: Option<u64>,
|
|
) -> ModelProviderInfo {
|
|
ModelProviderInfo {
|
|
name: "mock-ws".into(),
|
|
base_url: Some(format!("{}/v1", server.uri())),
|
|
env_key: None,
|
|
env_key_instructions: None,
|
|
experimental_bearer_token: None,
|
|
auth: None,
|
|
aws: None,
|
|
wire_api: WireApi::Responses,
|
|
query_params: None,
|
|
http_headers: None,
|
|
env_http_headers: None,
|
|
request_max_retries: Some(0),
|
|
stream_max_retries: Some(0),
|
|
stream_idle_timeout_ms: Some(5_000),
|
|
websocket_connect_timeout_ms,
|
|
requires_openai_auth: false,
|
|
supports_websockets: true,
|
|
}
|
|
}
|
|
|
|
async fn websocket_harness(server: &WebSocketTestServer) -> WebsocketTestHarness {
|
|
websocket_harness_with_runtime_metrics(server, /*runtime_metrics_enabled*/ false).await
|
|
}
|
|
|
|
async fn websocket_harness_with_runtime_metrics(
|
|
server: &WebSocketTestServer,
|
|
runtime_metrics_enabled: bool,
|
|
) -> WebsocketTestHarness {
|
|
websocket_harness_with_options(server, runtime_metrics_enabled).await
|
|
}
|
|
|
|
async fn websocket_harness_with_v2(
|
|
server: &WebSocketTestServer,
|
|
runtime_metrics_enabled: bool,
|
|
) -> WebsocketTestHarness {
|
|
websocket_harness_with_options(server, runtime_metrics_enabled).await
|
|
}
|
|
|
|
async fn websocket_harness_with_options(
|
|
server: &WebSocketTestServer,
|
|
runtime_metrics_enabled: bool,
|
|
) -> WebsocketTestHarness {
|
|
websocket_harness_with_provider_options(websocket_provider(server), runtime_metrics_enabled)
|
|
.await
|
|
}
|
|
|
|
async fn websocket_harness_with_provider_options(
|
|
provider: ModelProviderInfo,
|
|
runtime_metrics_enabled: bool,
|
|
) -> WebsocketTestHarness {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let mut config = load_default_config_for_test(&codex_home).await;
|
|
config.model = Some(MODEL.to_string());
|
|
if runtime_metrics_enabled {
|
|
config
|
|
.features
|
|
.enable(Feature::RuntimeMetrics)
|
|
.expect("test config should allow feature update");
|
|
}
|
|
let config = Arc::new(config);
|
|
let model_info = codex_core::test_support::construct_model_info_offline(MODEL, &config);
|
|
let thread_id = ThreadId::new();
|
|
let session_id = SessionId::new();
|
|
let auth_manager =
|
|
codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("Test API Key"));
|
|
let exporter = InMemoryMetricExporter::default();
|
|
let metrics = MetricsClient::new(
|
|
MetricsConfig::in_memory("test", "codex-core", env!("CARGO_PKG_VERSION"), exporter)
|
|
.with_runtime_reader(),
|
|
)
|
|
.expect("in-memory metrics client");
|
|
let session_telemetry = SessionTelemetry::new(
|
|
thread_id,
|
|
MODEL,
|
|
model_info.slug.as_str(),
|
|
/*account_id*/ None,
|
|
Some("test@test.com".to_string()),
|
|
auth_manager.auth_mode().map(TelemetryAuthMode::from),
|
|
"test_originator".to_string(),
|
|
/*log_user_prompts*/ false,
|
|
"test".to_string(),
|
|
SessionSource::Exec,
|
|
)
|
|
.with_metrics(metrics);
|
|
let effort = None;
|
|
let summary = ReasoningSummary::Auto;
|
|
let client = ModelClient::new(
|
|
/*auth_manager*/ None,
|
|
thread_id,
|
|
provider.clone(),
|
|
SessionSource::Exec,
|
|
config.model_verbosity,
|
|
/*enable_request_compression*/ false,
|
|
runtime_metrics_enabled,
|
|
/*beta_features_header*/ None,
|
|
/*attestation_provider*/ None,
|
|
);
|
|
|
|
WebsocketTestHarness {
|
|
_codex_home: codex_home,
|
|
client,
|
|
session_id,
|
|
thread_id,
|
|
model_info,
|
|
effort,
|
|
summary,
|
|
session_telemetry,
|
|
}
|
|
}
|
|
|
|
async fn stream_until_complete(
|
|
client_session: &mut ModelClientSession,
|
|
harness: &WebsocketTestHarness,
|
|
prompt: &Prompt,
|
|
) {
|
|
stream_until_complete_with_service_tier(
|
|
client_session,
|
|
harness,
|
|
prompt,
|
|
/*service_tier*/ None,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn stream_until_complete_with_model_info(
|
|
client_session: &mut ModelClientSession,
|
|
harness: &WebsocketTestHarness,
|
|
prompt: &Prompt,
|
|
model_info: &ModelInfo,
|
|
expected_response_id: &str,
|
|
) {
|
|
let responses_metadata = turn_metadata(harness, /*turn_id*/ None);
|
|
let mut stream = client_session
|
|
.stream(
|
|
prompt,
|
|
model_info,
|
|
&harness.session_telemetry,
|
|
harness.effort.clone(),
|
|
harness.summary,
|
|
/*service_tier*/ None,
|
|
&responses_metadata,
|
|
&codex_rollout_trace::InferenceTraceContext::disabled(),
|
|
)
|
|
.await
|
|
.expect("websocket stream failed");
|
|
|
|
while let Some(event) = stream.next().await {
|
|
match event {
|
|
Ok(ResponseEvent::Completed { response_id, .. }) => {
|
|
assert_eq!(response_id, expected_response_id);
|
|
return;
|
|
}
|
|
Ok(_) => {}
|
|
Err(err) => panic!("websocket stream failed: {err}"),
|
|
}
|
|
}
|
|
panic!("websocket stream ended before completion");
|
|
}
|
|
|
|
async fn stream_until_complete_with_service_tier(
|
|
client_session: &mut ModelClientSession,
|
|
harness: &WebsocketTestHarness,
|
|
prompt: &Prompt,
|
|
service_tier: Option<ServiceTier>,
|
|
) {
|
|
let responses_metadata = turn_metadata(harness, /*turn_id*/ None);
|
|
stream_until_complete_with_metadata(
|
|
client_session,
|
|
harness,
|
|
prompt,
|
|
service_tier,
|
|
&responses_metadata,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn stream_until_complete_with_metadata(
|
|
client_session: &mut ModelClientSession,
|
|
harness: &WebsocketTestHarness,
|
|
prompt: &Prompt,
|
|
service_tier: Option<ServiceTier>,
|
|
responses_metadata: &CodexResponsesMetadata,
|
|
) {
|
|
let mut stream = client_session
|
|
.stream(
|
|
prompt,
|
|
&harness.model_info,
|
|
&harness.session_telemetry,
|
|
harness.effort.clone(),
|
|
harness.summary,
|
|
service_tier.map(|service_tier| service_tier.request_value().to_string()),
|
|
responses_metadata,
|
|
&codex_rollout_trace::InferenceTraceContext::disabled(),
|
|
)
|
|
.await
|
|
.expect("websocket stream failed");
|
|
|
|
while let Some(event) = stream.next().await {
|
|
if matches!(event, Ok(ResponseEvent::Completed { .. })) {
|
|
break;
|
|
}
|
|
}
|
|
}
|