mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[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:
committed by
GitHub
Unverified
parent
df7818c7d1
commit
954e2878bb
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user