Send model routing hints to the Codex backend (#37345)

## What changed

- Add an `x-codex-routing-hint` header containing the request model and, when selected, the service tier.
- Send the hint on Responses HTTP requests, remote compaction requests, and WebSocket handshakes, including prewarm connections.
- Limit the header to Codex-backend authentication with the OpenAI provider, omitting it for API-key and provider-specific credential paths.

## Testing

- Cover routing hints for HTTP, remote compaction, WebSocket streaming, and tiered prewarming, along with the credential paths where the header must be absent.

GitOrigin-RevId: d8d415d7fadddecf9a132d05920b856a35ac9960
This commit is contained in:
thorvald-oai
2026-08-07 00:58:54 +00:00
committed by copyberry
parent 4ffeddcbcc
commit 270d93268c
6 changed files with 243 additions and 7 deletions
+52 -1
View File
@@ -141,6 +141,7 @@ use codex_response_debug_context::telemetry_transport_error_message;
pub const OPENAI_BETA_HEADER: &str = "OpenAI-Beta";
pub const X_CODEX_INSTALLATION_ID_HEADER: &str = "x-codex-installation-id";
pub const X_CODEX_ROUTING_HINT_HEADER: &str = "x-codex-routing-hint";
pub const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state";
pub const X_CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata";
pub const X_CODEX_PARENT_THREAD_ID_HEADER: &str = "x-codex-parent-thread-id";
@@ -626,6 +627,13 @@ impl ModelClient {
if let Some(header_value) = self.generate_attestation_header_for().await {
extra_headers.insert(X_OAI_ATTESTATION_HEADER, header_value);
}
if let Some(header_value) = self.build_routing_hint_header(
client_setup.auth.as_ref(),
&model,
service_tier.as_deref(),
) {
extra_headers.insert(X_CODEX_ROUTING_HINT_HEADER, header_value);
}
add_responses_lite_header(&mut extra_headers, model_info.use_responses_lite);
let compact_request_timeout = client_setup
.api_provider
@@ -980,6 +988,31 @@ impl ModelClient {
})
}
fn build_routing_hint_header(
&self,
auth: Option<&CodexAuth>,
model: &str,
service_tier: Option<&str>,
) -> Option<HeaderValue> {
let provider = self.state.provider.info();
if !auth.is_some_and(CodexAuth::uses_codex_backend)
|| !provider.is_openai()
|| !provider.requires_openai_auth
|| provider.env_key.is_some()
|| provider.experimental_bearer_token.is_some()
|| provider.auth.is_some()
|| provider.aws.is_some()
{
return None;
}
let routing_hint = match service_tier {
Some(tier) => format!("model={model};tier={tier}"),
None => format!("model={model}"),
};
HeaderValue::from_str(&routing_hint).ok()
}
fn build_api_transport(
&self,
api_provider: &ApiProvider,
@@ -1106,6 +1139,9 @@ impl ModelClient {
Some(responses_metadata.thread_id.to_string()),
));
headers.extend(self.build_responses_compatibility_headers(responses_metadata));
if let Some(routing_hint) = &responses_metadata.routing_hint {
headers.insert(X_CODEX_ROUTING_HINT_HEADER, routing_hint.clone());
}
if let Some(header_value) = self.generate_attestation_header_for().await {
headers.insert(X_OAI_ATTESTATION_HEADER, header_value);
}
@@ -1454,6 +1490,15 @@ impl ModelClientSession {
service_tier.clone(),
responses_metadata,
)?;
if let Some(header_value) = self.client.build_routing_hint_header(
client_setup.auth.as_ref(),
&request.model,
request.service_tier.as_deref(),
) {
options
.extra_headers
.insert(X_CODEX_ROUTING_HINT_HEADER, header_value);
}
self.client
.prepare_response_items_for_request(&mut request.input);
let request_session_telemetry =
@@ -1566,6 +1611,12 @@ impl ModelClientSession {
service_tier.clone(),
responses_metadata,
)?;
let mut websocket_metadata = responses_metadata.clone();
websocket_metadata.routing_hint = self.client.build_routing_hint_header(
client_setup.auth.as_ref(),
&request.model,
request.service_tier.as_deref(),
);
let request_session_telemetry = if warmup {
// `generate=false` prewarm is connection setup, not an inference request.
session_telemetry.clone()
@@ -1583,7 +1634,7 @@ impl ModelClientSession {
session_telemetry,
api_provider: client_setup.api_provider,
api_auth: client_setup.api_auth,
responses_metadata,
responses_metadata: &websocket_metadata,
auth_context: request_auth_context,
request_route_telemetry: RequestRouteTelemetry::for_endpoint(
RESPONSES_ENDPOINT,
+1
View File
@@ -184,6 +184,7 @@ pub use attestation::GenerateAttestationFuture;
pub use client::ModelClient;
pub use client::ModelClientSession;
pub use client::X_CODEX_INSTALLATION_ID_HEADER;
pub use client::X_CODEX_ROUTING_HINT_HEADER;
pub use client::X_CODEX_TURN_METADATA_HEADER;
pub use client_common::Prompt;
pub use client_common::ResponseEvent;
+2
View File
@@ -161,6 +161,7 @@ pub struct CodexResponsesMetadata {
pub(crate) session_id: String,
pub(crate) thread_id: String,
pub(crate) turn_id: Option<String>,
pub(crate) routing_hint: Option<HeaderValue>,
pub(crate) window_id: String,
pub(crate) request_kind: Option<CodexResponsesRequestKind>,
pub(crate) forked_from_thread_id: Option<ThreadId>,
@@ -188,6 +189,7 @@ impl CodexResponsesMetadata {
session_id,
thread_id,
turn_id: None,
routing_hint: None,
window_id,
request_kind: None,
forked_from_thread_id: None,
+23 -2
View File
@@ -5,6 +5,7 @@ use codex_core::Prompt;
use codex_core::ResponseEvent;
use codex_core::StartThreadOptions;
use codex_core::ThreadManager;
use codex_core::X_CODEX_ROUTING_HINT_HEADER;
use codex_core::resolve_installation_id;
use codex_core::thread_store_from_config;
use codex_extension_api::empty_extension_registry;
@@ -1326,6 +1327,7 @@ async fn includes_session_id_thread_id_and_model_headers_in_request() {
let request = resp_mock.single_request();
assert_eq!(request.path(), "/v1/responses");
assert_eq!(request.header(X_CODEX_ROUTING_HINT_HEADER), None);
let request_session_id = request.header("session-id").expect("session-id header");
let request_thread_id = request.header("thread-id").expect("thread-id header");
let request_authorization = request
@@ -1656,6 +1658,13 @@ async fn chatgpt_auth_sends_correct_request() {
.header("chatgpt-account-id")
.expect("chatgpt-account-id header");
let request_body = request.body_json();
let model = request_body["model"]
.as_str()
.expect("missing request model");
assert_eq!(
request.header(X_CODEX_ROUTING_HINT_HEADER),
Some(format!("model={model}"))
);
let request_session_id = request.header("session-id").expect("session-id header");
let request_thread_id = request.header("thread-id").expect("thread-id header");
@@ -2525,6 +2534,7 @@ async fn sequential_cutoff_is_omitted_for_non_openai_provider() -> anyhow::Resul
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(|config| {
config.model_provider.name = "mock".to_string();
let _ = config
@@ -2550,7 +2560,9 @@ async fn sequential_cutoff_is_omitted_for_non_openai_provider() -> anyhow::Resul
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let request_body = resp_mock.single_request().body_json();
let request = resp_mock.single_request();
assert_eq!(request.header(X_CODEX_ROUTING_HINT_HEADER), None);
let request_body = request.body_json();
pretty_assertions::assert_eq!(request_body.get("stream_options"), None);
Ok(())
@@ -3766,7 +3778,7 @@ async fn env_var_overrides_loaded_auth() {
.await;
let provider = ModelProviderInfo {
name: "custom".to_string(),
name: ModelProviderInfo::create_openai_provider(/*base_url*/ None).name,
base_url: Some(format!("{}/openai", server.uri())),
// Reuse the existing environment variable to avoid using unsafe code
env_key: Some(EXISTING_ENV_VAR_WITH_NON_EMPTY_VALUE.to_string()),
@@ -3820,6 +3832,15 @@ async fn env_var_overrides_loaded_auth() {
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let request = server
.received_requests()
.await
.expect("read recorded requests")
.into_iter()
.find(|request| request.url.path() == "/openai/responses")
.expect("missing provider request");
assert_eq!(request.headers.get(X_CODEX_ROUTING_HINT_HEADER), None);
}
fn create_dummy_codex_auth() -> CodexAuth {
+136 -4
View File
@@ -6,6 +6,7 @@ use codex_core::ModelClient;
use codex_core::ModelClientSession;
use codex_core::Prompt;
use codex_core::ResponseEvent;
use codex_core::X_CODEX_ROUTING_HINT_HEADER;
use codex_core::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER;
use codex_core::test_support::with_parent_turn;
use codex_features::Feature;
@@ -29,6 +30,7 @@ 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::ModelServiceTier;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
@@ -160,7 +162,7 @@ async fn responses_websocket_streams_request() {
]]])
.await;
let harness = websocket_harness(&server).await;
let harness = websocket_harness_for_codex_backend(&server).await;
let mut client_session = harness.client.new_session();
let prompt = prompt_with_input(vec![message_item("hello")]);
@@ -179,6 +181,10 @@ async fn responses_websocket_streams_request() {
handshake.header(OPENAI_BETA_HEADER),
Some(WS_V2_BETA_HEADER_VALUE.to_string())
);
assert_eq!(
handshake.header(X_CODEX_ROUTING_HINT_HEADER),
Some(format!("model={MODEL}"))
);
assert_eq!(
handshake.header(X_CLIENT_REQUEST_ID_HEADER),
Some(harness.thread_id.to_string())
@@ -210,6 +216,42 @@ async fn responses_websocket_streams_request() {
server.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn responses_websocket_omits_routing_hint_for_provider_with_own_credentials() {
skip_if_no_network!();
let server = start_websocket_server(vec![vec![vec![
ev_response_created("resp-1"),
ev_completed("resp-1"),
]]])
.await;
let mut provider = websocket_provider(&server);
provider.name = ModelProviderInfo::create_openai_provider(/*base_url*/ None).name;
provider.experimental_bearer_token = Some("provider-specific-token".to_string());
let harness = websocket_harness_with_provider_options_and_auth(
provider,
/*runtime_metrics_enabled*/ false,
/*concurrent_reasoning_summaries_enabled*/ false,
/*enabled_features*/ &[],
Some(CodexAuth::create_dummy_chatgpt_auth_for_testing()),
)
.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("authorization"),
Some("Bearer provider-specific-token".to_string())
);
assert_eq!(handshake.header(X_CODEX_ROUTING_HINT_HEADER), None);
server.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn responses_websocket_omits_unprefixed_item_ids_without_mutating_prompt() {
skip_if_no_network!();
@@ -913,6 +955,59 @@ async fn responses_websocket_request_prewarm_is_reused_even_with_header_changes(
server.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn responses_websocket_prewarm_includes_model_and_tier_routing_hint() {
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_for_codex_backend(&server).await;
let mut model_info = harness.model_info.clone();
let service_tier = ServiceTier::Fast.request_value();
model_info.service_tiers.push(ModelServiceTier {
id: service_tier.to_string(),
name: "Fast".to_string(),
description: "Priority processing".to_string(),
});
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,
&model_info,
&harness.session_telemetry,
harness.effort.clone(),
harness.summary,
Some(service_tier.to_string()),
&responses_metadata,
)
.await
.expect("websocket prewarm failed");
let handshake = server.single_handshake();
assert_eq!(
handshake.header(X_CODEX_ROUTING_HINT_HEADER),
Some(format!("model={MODEL};tier={service_tier}"))
);
assert_eq!(
handshake.header(OPENAI_BETA_HEADER),
Some(WS_V2_BETA_HEADER_VALUE.to_string())
);
let connection = server.single_connection();
let prewarm = connection
.first()
.expect("missing prewarm request")
.body_json();
assert_eq!(prewarm["service_tier"].as_str(), Some(service_tier));
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!();
@@ -995,6 +1090,12 @@ async fn responses_websocket_preconnect_runs_when_only_v2_feature_enabled() {
server.single_handshake().header("x-codex-turn-metadata"),
None
);
assert_eq!(
server
.single_handshake()
.header(X_CODEX_ROUTING_HINT_HEADER),
None
);
let prompt = prompt_with_input(vec![message_item("hello")]);
stream_until_complete(&mut client_session, &harness, &prompt).await;
@@ -2300,6 +2401,18 @@ async fn websocket_harness(server: &WebSocketTestServer) -> WebsocketTestHarness
websocket_harness_with_runtime_metrics(server, /*runtime_metrics_enabled*/ false).await
}
async fn websocket_harness_for_codex_backend(server: &WebSocketTestServer) -> WebsocketTestHarness {
let provider = ModelProviderInfo::create_openai_provider(Some(format!("{}/v1", server.uri())));
websocket_harness_with_provider_options_and_auth(
provider,
/*runtime_metrics_enabled*/ false,
/*concurrent_reasoning_summaries_enabled*/ false,
/*enabled_features*/ &[],
Some(CodexAuth::create_dummy_chatgpt_auth_for_testing()),
)
.await
}
async fn websocket_harness_with_runtime_metrics(
server: &WebSocketTestServer,
runtime_metrics_enabled: bool,
@@ -2332,6 +2445,23 @@ async fn websocket_harness_with_provider_options(
runtime_metrics_enabled: bool,
concurrent_reasoning_summaries_enabled: bool,
enabled_features: &[Feature],
) -> WebsocketTestHarness {
websocket_harness_with_provider_options_and_auth(
provider,
runtime_metrics_enabled,
concurrent_reasoning_summaries_enabled,
enabled_features,
/*auth*/ None,
)
.await
}
async fn websocket_harness_with_provider_options_and_auth(
provider: ModelProviderInfo,
runtime_metrics_enabled: bool,
concurrent_reasoning_summaries_enabled: bool,
enabled_features: &[Feature],
auth: Option<CodexAuth>,
) -> WebsocketTestHarness {
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home).await;
@@ -2361,8 +2491,10 @@ async fn websocket_harness_with_provider_options(
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 client_auth_manager = auth.map(codex_core::test_support::auth_manager_from_auth);
let auth_manager = client_auth_manager.clone().unwrap_or_else(|| {
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)
@@ -2385,7 +2517,7 @@ async fn websocket_harness_with_provider_options(
let effort = None;
let summary = ReasoningSummary::Auto;
let client = ModelClient::new(
/*auth_manager*/ None,
client_auth_manager,
AgentIdentityAuthPolicy::JwtOnly,
thread_id,
provider.clone(),
@@ -5,6 +5,7 @@ use anyhow::Result;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_core::StartThreadOptions;
use codex_core::X_CODEX_ROUTING_HINT_HEADER;
use codex_core::compact::SUMMARY_PREFIX;
use codex_features::Feature;
use codex_login::CodexAuth;
@@ -790,6 +791,14 @@ async fn remote_compact_uses_agent_identity_assertion() -> Result<()> {
compact_request.header("chatgpt-account-id").as_deref(),
Some("account-compact")
);
let compact_body = compact_request.body_json();
let model = compact_body["model"]
.as_str()
.expect("missing request model");
assert_eq!(
compact_request.header(X_CODEX_ROUTING_HINT_HEADER),
Some(format!("model={model}"))
);
Ok(())
}
@@ -801,6 +810,7 @@ async fn assert_remote_manual_compact_request_parity(
snapshot_name: &str,
scenario: &str,
) -> Result<()> {
let uses_codex_backend = auth.uses_codex_backend();
let mut builder = test_codex().with_auth(auth);
if let Some(service_tier) = configured_service_tier {
builder = builder.with_config(move |config| {
@@ -969,6 +979,25 @@ async fn assert_remote_manual_compact_request_parity(
let compact_request = compact_mock.single_request();
let normal_body = normal_request.body_json();
let compact_body = compact_request.body_json();
let expected_routing_hint = |body: &Value| {
if !uses_codex_backend {
return None;
}
let model = body["model"].as_str().expect("missing request model");
match body.get("service_tier").and_then(Value::as_str) {
Some(tier) => Some(format!("model={model};tier={tier}")),
None => Some(format!("model={model}")),
}
};
assert_eq!(
normal_request.header(X_CODEX_ROUTING_HINT_HEADER),
expected_routing_hint(&normal_body)
);
assert_eq!(
compact_request.header(X_CODEX_ROUTING_HINT_HEADER),
expected_routing_hint(&compact_body)
);
let mut expected_compact_body_without_input = normal_body.clone();
let expected_compact_object = expected_compact_body_without_input