[codex] Send Responses Lite transport header (#26542)

## Summary

- send `X-OpenAI-Internal-Codex-Responses-Lite: true` on HTTP Responses
requests and WebSocket upgrade requests when model metadata enables
Responses Lite
- use client metadata when sending it over the websocket

This PR is stacked on #26490.

## Why

The Responses Lite marker is request-scoped for HTTP but
connection-scoped for Responses-over-WebSocket because it is carried on
the upgrade request. Reusing a cached socket opened for the opposite
mode would therefore send the wrong transport contract.

## Validation

- `just test -p codex-core responses_lite`
- `just test -p codex-core
responses_websocket_reconnects_when_responses_lite_mode_changes`
- `just fix -p codex-core`
- `just fmt`
This commit is contained in:
rka-oai
2026-06-05 18:01:20 -07:00
committed by GitHub
Unverified
parent df7818c7d1
commit 954e2878bb
4 changed files with 218 additions and 4 deletions
+37 -3
View File
@@ -143,7 +143,11 @@ pub const X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER: &str =
"x-responsesapi-include-timing-metrics";
const X_CODEX_WS_STREAM_REQUEST_START_MS_CLIENT_METADATA_KEY: &str =
"x-codex-ws-stream-request-start-ms";
const WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY: &str =
"ws_request_header_x_openai_internal_codex_responses_lite";
const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06";
const X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER: &str =
"x-openai-internal-codex-responses-lite";
const RESPONSES_ENDPOINT: &str = "/responses";
const RESPONSES_COMPACT_ENDPOINT: &str = "/responses/compact";
// `/responses/compact` is unary, so the timeout covers the full response rather than one idle
@@ -526,6 +530,7 @@ impl ModelClient {
if let Some(header_value) = self.generate_attestation_header_for().await {
extra_headers.insert(X_OAI_ATTESTATION_HEADER, header_value);
}
add_responses_lite_header(&mut extra_headers, model_info.use_responses_lite);
let compact_request_timeout = client_setup
.api_provider
.stream_idle_timeout
@@ -655,6 +660,7 @@ impl ModelClient {
fn build_ws_client_metadata(
&self,
turn_metadata_header: Option<&str>,
use_responses_lite: bool,
) -> HashMap<String, String> {
let mut client_metadata = HashMap::new();
client_metadata.insert(
@@ -682,6 +688,12 @@ impl ModelClient {
turn_metadata.to_string(),
);
}
if use_responses_lite {
client_metadata.insert(
WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY.to_string(),
"true".to_string(),
);
}
client_metadata
}
@@ -982,6 +994,7 @@ impl ModelClientSession {
&self,
turn_metadata_header: Option<&str>,
compression: Compression,
use_responses_lite: bool,
) -> ApiResponsesOptions {
let turn_metadata_header = parse_turn_metadata_header(turn_metadata_header);
let session_id = self.client.state.session_id.to_string();
@@ -1000,6 +1013,7 @@ impl ModelClientSession {
if let Some(header_value) = self.client.generate_attestation_header_for().await {
headers.insert(X_OAI_ATTESTATION_HEADER, header_value);
}
add_responses_lite_header(&mut headers, use_responses_lite);
headers
},
compression,
@@ -1267,7 +1281,11 @@ impl ModelClientSession {
);
let compression = self.responses_request_compression(client_setup.auth.as_ref());
let mut options = self
.build_responses_options(turn_metadata_header, compression)
.build_responses_options(
turn_metadata_header,
compression,
model_info.use_responses_lite,
)
.await;
let request = self.client.build_responses_request(
@@ -1377,7 +1395,11 @@ impl ModelClientSession {
let compression = self.responses_request_compression(client_setup.auth.as_ref());
let options = self
.build_responses_options(turn_metadata_header, compression)
.build_responses_options(
turn_metadata_header,
compression,
model_info.use_responses_lite,
)
.await;
let request = self.client.build_responses_request(
&client_setup.api_provider,
@@ -1389,7 +1411,10 @@ impl ModelClientSession {
)?;
let mut ws_payload = ResponseCreateWsRequest {
client_metadata: response_create_client_metadata(
Some(self.client.build_ws_client_metadata(turn_metadata_header)),
Some(self.client.build_ws_client_metadata(
turn_metadata_header,
model_info.use_responses_lite,
)),
request_trace.as_ref(),
),
..ResponseCreateWsRequest::from(&request)
@@ -1707,6 +1732,15 @@ fn build_responses_headers(
headers
}
fn add_responses_lite_header(headers: &mut ApiHeaderMap, use_responses_lite: bool) {
if use_responses_lite {
headers.insert(
X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER,
HeaderValue::from_static("true"),
);
}
}
fn subagent_header_value(session_source: &SessionSource) -> Option<String> {
match session_source {
SessionSource::SubAgent(subagent_source) => match subagent_source {
+4 -1
View File
@@ -293,7 +293,10 @@ fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() {
client.advance_window_generation();
let client_metadata = client.build_ws_client_metadata(Some(r#"{"turn_id":"turn-123"}"#));
let client_metadata = client.build_ws_client_metadata(
Some(r#"{"turn_id":"turn-123"}"#),
/*use_responses_lite*/ false,
);
let thread_id = client.state.thread_id;
assert_eq!(
client_metadata,
@@ -62,6 +62,8 @@ 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 X_CODEX_WS_STREAM_REQUEST_START_MS_CLIENT_METADATA_KEY: &str =
"x-codex-ws-stream-request-start-ms";
@@ -512,6 +514,85 @@ async fn responses_websocket_reuses_connection_after_session_drop() {
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!();
@@ -2028,6 +2109,40 @@ async fn stream_until_complete(
.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 mut stream = client_session
.stream(
prompt,
model_info,
&harness.session_telemetry,
harness.effort.clone(),
harness.summary,
/*service_tier*/ None,
/*turn_metadata_header*/ None,
&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,
@@ -10,12 +10,18 @@ use codex_image_generation_extension::install as install_image_generation_extens
use codex_login::CodexAuth;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::openai_models::InputModality;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_web_search_extension::install as install_web_search_extension;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use serde_json::Value;
const RESPONSES_LITE_HEADER: &str = "x-openai-internal-codex-responses-lite";
fn responses_extensions(auth: &CodexAuth) -> Arc<ExtensionRegistry<Config>> {
let auth_manager = codex_core::test_support::auth_manager_from_auth(auth.clone());
let mut extension_builder = ExtensionRegistryBuilder::<Config>::new();
@@ -76,6 +82,10 @@ async fn responses_lite_uses_standalone_web_search_and_image_generation() -> Res
test.submit_turn("Use standalone tools").await?;
let request = response_mock.single_request();
assert_eq!(
request.header(RESPONSES_LITE_HEADER).as_deref(),
Some("true")
);
request
.tool_by_name("web", "run")
.context("Responses Lite should expose standalone web search")?;
@@ -93,6 +103,57 @@ async fn responses_lite_uses_standalone_web_search_and_image_generation() -> Res
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn responses_lite_compact_request_uses_lite_transport_contract() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let response_mock = responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_completed("resp-1"),
]),
)
.await;
let compact_mock =
responses::mount_compact_json_once(&server, serde_json::json!({ "output": [] })).await;
let mut builder = test_codex().with_model_info_override("gpt-5.4", |model_info| {
model_info.use_responses_lite = true;
model_info.supports_parallel_tool_calls = true;
});
let test = builder.build(&server).await?;
test.submit_turn("Compact this conversation").await?;
test.codex.submit(Op::Compact).await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
response_mock.single_request();
let compact_request = compact_mock.single_request();
assert_eq!(
compact_request.header(RESPONSES_LITE_HEADER).as_deref(),
Some("true")
);
let compact_body = compact_request.body_json();
assert_eq!(
compact_body
.get("reasoning")
.and_then(|reasoning| reasoning.get("context"))
.and_then(Value::as_str),
Some("all_turns")
);
assert_eq!(
compact_body.get("parallel_tool_calls"),
Some(&Value::Bool(false))
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn responses_lite_omits_hosted_tools_without_standalone_extensions() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -154,6 +215,7 @@ async fn non_lite_uses_hosted_tools_when_standalone_features_are_disabled() -> R
test.submit_turn("Use hosted tools").await?;
let request = response_mock.single_request();
assert_eq!(request.header(RESPONSES_LITE_HEADER), None);
assert!(request.tool_by_name("web", "run").is_none());
assert!(request.tool_by_name("image_gen", "imagegen").is_none());
let body = request.body_json();