diff --git a/backend/apps/gateway/tests/bootstrap/config.rs b/backend/apps/gateway/tests/bootstrap/config.rs index 4ddbf5806..694ca11af 100644 --- a/backend/apps/gateway/tests/bootstrap/config.rs +++ b/backend/apps/gateway/tests/bootstrap/config.rs @@ -370,10 +370,28 @@ fn config_loader_should_reject_zero_server_port() { } #[test] -fn config_loader_should_validate_openai_residency() { +fn config_loader_should_accept_optional_openai_routing_strings() { + for value in [ + serde_json::Value::Null, + serde_json::json!("us"), + serde_json::json!("custom-value"), + serde_json::json!(""), + ] { + let mut document = valid_config_document(); + document["openai"]["residency"] = value.clone(); + document["openai"]["account_routing_override"] = value; + parse_config(&document.to_string()).expect("optional routing strings"); + } let mut document = valid_config_document(); - document["openai"]["residency"] = serde_json::json!("invalid"); - assert_rejected(document.to_string()); + document["openai"] + .as_object_mut() + .unwrap() + .remove("residency"); + document["openai"] + .as_object_mut() + .unwrap() + .remove("account_routing_override"); + parse_config(&document.to_string()).expect("omitted routing strings"); } fn assert_rejected(config: String) { diff --git a/backend/crates/providers/openai/src/config.rs b/backend/crates/providers/openai/src/config.rs index 87c8355ee..790ccd2a0 100644 --- a/backend/crates/providers/openai/src/config.rs +++ b/backend/crates/providers/openai/src/config.rs @@ -7,7 +7,6 @@ use serde::Deserialize; use url::Url; use crate::credential::CodexQuotaRefreshPolicy; -use crate::transport::profile::CodexResidency; use crate::transport::session::{CodexSessionIdentity, CodexSessionIdentityError}; use crate::transport::websocket::CodexWebSocketPoolConfig; use crate::{ @@ -42,7 +41,9 @@ pub struct OpenAiConfig { #[serde(default = "default_stream_max_retries")] pub stream_max_retries: u64, #[serde(default)] - pub residency: Option, + pub residency: Option, + #[serde(default)] + pub account_routing_override: Option, #[serde(skip)] identity_secret_path: PathBuf, } @@ -124,6 +125,7 @@ impl Default for OpenAiConfig { auth: CodexAuthSettings::default(), stream_max_retries: DEFAULT_STREAM_MAX_RETRIES, residency: None, + account_routing_override: None, identity_secret_path: PathBuf::new(), } } diff --git a/backend/crates/providers/openai/src/lib.rs b/backend/crates/providers/openai/src/lib.rs index 88a629858..fc9ce07d3 100644 --- a/backend/crates/providers/openai/src/lib.rs +++ b/backend/crates/providers/openai/src/lib.rs @@ -83,7 +83,8 @@ pub async fn initialize( let credential_state = ports.credential_state(); let profile = transport::profile::CodexWireProfileState::new(transport::profile::CodexWireProfile { - residency: config.residency, + residency: config.residency.clone(), + account_routing_override: config.account_routing_override.clone(), ..Default::default() }); let artifact_cache = diff --git a/backend/crates/providers/openai/src/transport/client_sse.rs b/backend/crates/providers/openai/src/transport/client_sse.rs index 24f437014..bc5a42ad7 100644 --- a/backend/crates/providers/openai/src/transport/client_sse.rs +++ b/backend/crates/providers/openai/src/transport/client_sse.rs @@ -734,11 +734,13 @@ fn websocket_connection_profile( X_OPENAI_MEMGEN_REQUEST_HEADER, "x-codex-guardian", "x-openai-internal-codex-residency", + "x-openai-account-routing-override", ] .map(|name| { + // 空字符串也是显式配置,必须与未发送的请求头区分 headers .get(name) - .and_then(|value| value.to_str().ok()) + .map(|value| format!("{name}:{}", hex::encode(value.as_bytes()))) .unwrap_or_default() }) .join("\0"); diff --git a/backend/crates/providers/openai/src/transport/headers.rs b/backend/crates/providers/openai/src/transport/headers.rs index 9e1377d2f..b4e35ed4c 100644 --- a/backend/crates/providers/openai/src/transport/headers.rs +++ b/backend/crates/providers/openai/src/transport/headers.rs @@ -11,11 +11,9 @@ use super::client::{ CodexBackendClient, CodexClientResult, CodexRequestContext, OpenAiUpstreamProtocol, openai_subagent_from_metadata, }; -use super::profile::{CodexResidency, CodexWireProfile}; +use super::profile::CodexWireProfile; use super::protocol::responses::CodexResponsesRequest; -const CODEX_RESIDENCY_HEADER: &str = "x-openai-internal-codex-residency"; - /// 不透传下游携带的认证、账号及相关身份字段,避免影响网关选定的上游身份 /// 上游需要的官方身份头由网关构造;这里也包含通用认证和 Cookie 字段 pub(super) fn is_managed_identity_header(name: &str) -> bool { @@ -70,12 +68,16 @@ pub fn build_codex_profile_headers(profile: &CodexWireProfile) -> CodexClientRes HeaderValue::from_str(&profile.originator)?, ); headers.insert(USER_AGENT, HeaderValue::from_str(&profile.user_agent())?); - if let Some(CodexResidency::Us) = profile.residency { - headers.insert( - HeaderName::from_static(CODEX_RESIDENCY_HEADER), - HeaderValue::from_static("us"), - ); - } + insert_optional_header( + &mut headers, + "x-openai-internal-codex-residency", + profile.residency.as_deref(), + )?; + insert_optional_header( + &mut headers, + "x-openai-account-routing-override", + profile.account_routing_override.as_deref(), + )?; Ok(headers) } diff --git a/backend/crates/providers/openai/src/transport/profile/identity.rs b/backend/crates/providers/openai/src/transport/profile/identity.rs index 9a020252b..e45ae95a6 100644 --- a/backend/crates/providers/openai/src/transport/profile/identity.rs +++ b/backend/crates/providers/openai/src/transport/profile/identity.rs @@ -157,6 +157,7 @@ impl ExactIdentity { fn profile(&self, state: &CodexWireProfileState) -> CodexWireProfile { let environment = self.environment.as_ref(); + let deployment = state.snapshot(); CodexWireProfile { client_kind: if self.originator == "Codex Desktop" { ClientKind::Desktop @@ -180,7 +181,8 @@ impl ExactIdentity { .map(|value| value.terminal.clone()) .unwrap_or_default(), exact_user_agent: Some(self.user_agent.clone()), - residency: state.snapshot().residency, + residency: deployment.residency, + account_routing_override: deployment.account_routing_override, verified_at: DateTime::UNIX_EPOCH, } } diff --git a/backend/crates/providers/openai/src/transport/profile/mod.rs b/backend/crates/providers/openai/src/transport/profile/mod.rs index 7a9f21096..05161bebd 100644 --- a/backend/crates/providers/openai/src/transport/profile/mod.rs +++ b/backend/crates/providers/openai/src/transport/profile/mod.rs @@ -40,13 +40,6 @@ const MAX_APPCAST_BYTES: usize = 1024 * 1024; const ARTIFACT_PROFILE_CACHE_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60); const ARTIFACT_PROFILE_SCHEMA_VERSION: u64 = 1; -/// 与官方 managed residency requirement 相同的可选请求约束 -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum CodexResidency { - Us, -} - /// 保留已有配置类型入口;位置规则由出口领域统一维护 pub use gateway_core::account::RequestLocation as CodexRequestLocation; @@ -77,7 +70,9 @@ pub struct CodexWireProfile { #[serde(default, skip_serializing_if = "Option::is_none")] pub exact_user_agent: Option, /// 未配置时不发送 residency 头;不随制品版本更新而改变 - pub residency: Option, + pub residency: Option, + /// 部署指定的账号路由头;未配置时不发送,不随客户端身份选择改变 + pub account_routing_override: Option, /// 版本元组最后一次经制品核验的时间;不表示 TLS 传输已重新核验 pub verified_at: DateTime, } @@ -96,6 +91,7 @@ impl Default for CodexWireProfile { terminal: "unknown".to_owned(), exact_user_agent: None, residency: None, + account_routing_override: None, // 制品核验于 2026-09-06T03:26:12.084Z;进程启动不构成重新核验 verified_at: DateTime::UNIX_EPOCH + chrono::Duration::milliseconds(1_788_665_172_084), } diff --git a/backend/crates/providers/openai/src/transport/profile/selection.rs b/backend/crates/providers/openai/src/transport/profile/selection.rs index 92d0ff97f..770e62ecf 100644 --- a/backend/crates/providers/openai/src/transport/profile/selection.rs +++ b/backend/crates/providers/openai/src/transport/profile/selection.rs @@ -248,6 +248,7 @@ impl ClientProfileSelection { verified_at: None, }, }; + let deployment = state.snapshot(); let mut profile = CodexWireProfile { client_kind: self.client, originator: self @@ -271,7 +272,8 @@ impl ClientProfileSelection { .clone() .unwrap_or_else(|| "unknown".to_owned()), exact_user_agent: None, - residency: state.snapshot().residency, + residency: deployment.residency, + account_routing_override: deployment.account_routing_override, verified_at: release.verified_at.unwrap_or(DateTime::UNIX_EPOCH), }; if let Some(entry) = self.cli_entry { diff --git a/backend/crates/providers/openai/tests/config.rs b/backend/crates/providers/openai/tests/config.rs index 18632cdfd..6c9cce75f 100644 --- a/backend/crates/providers/openai/tests/config.rs +++ b/backend/crates/providers/openai/tests/config.rs @@ -5,11 +5,36 @@ use std::path::Path; use provider_openai::config::{MAX_STREAM_MAX_RETRIES, OpenAiConfig}; #[test] -fn openai_config_keeps_residency_separate_from_client_identity() { - use provider_openai::transport::profile::CodexResidency; - let config: OpenAiConfig = serde_json::from_str(r#"{"residency":"us"}"#).unwrap(); - assert_eq!(config.residency, Some(CodexResidency::Us)); - assert!(serde_json::from_str::(r#"{"residency":"invalid"}"#).is_err()); +fn openai_config_preserves_optional_routing_header_values() { + for (document, expected) in [ + (r#"{}"#, (None, None)), + ( + r#"{"residency":null,"account_routing_override":null}"#, + (None, None), + ), + ( + r#"{"residency":"us","account_routing_override":"us"}"#, + (Some("us"), Some("us")), + ), + ( + r#"{"residency":"custom-region","account_routing_override":"custom-route"}"#, + (Some("custom-region"), Some("custom-route")), + ), + ( + r#"{"residency":"","account_routing_override":"NO_CONSTRAINT"}"#, + (Some(""), Some("NO_CONSTRAINT")), + ), + ] { + let config: OpenAiConfig = serde_json::from_str(document).unwrap(); + assert_eq!( + ( + config.residency.as_deref(), + config.account_routing_override.as_deref() + ), + expected, + "{document}", + ); + } } #[test] diff --git a/backend/crates/providers/openai/tests/credential/catalog.rs b/backend/crates/providers/openai/tests/credential/catalog.rs index b35be886f..5f77b0074 100644 --- a/backend/crates/providers/openai/tests/credential/catalog.rs +++ b/backend/crates/providers/openai/tests/credential/catalog.rs @@ -69,6 +69,7 @@ fn wire_profile() -> CodexWireProfileState { terminal: "catalog-contract".to_owned(), exact_user_agent: None, residency: None, + account_routing_override: None, verified_at: Utc .with_ymd_and_hms(2026, 7, 18, 0, 0, 0) .single() diff --git a/backend/crates/providers/openai/tests/credential/contract/mod.rs b/backend/crates/providers/openai/tests/credential/contract/mod.rs index 1e7bb4333..388ad6be2 100644 --- a/backend/crates/providers/openai/tests/credential/contract/mod.rs +++ b/backend/crates/providers/openai/tests/credential/contract/mod.rs @@ -228,6 +228,7 @@ fn selector_with_runtime( terminal: "selector-contract".to_owned(), exact_user_agent: None, residency: None, + account_routing_override: None, verified_at: chrono::Utc::now(), }); let http = reqwest::Client::builder().build().expect("HTTP client"); diff --git a/backend/crates/providers/openai/tests/credential/profile_statistics.rs b/backend/crates/providers/openai/tests/credential/profile_statistics.rs index fe51d0826..24ca855be 100644 --- a/backend/crates/providers/openai/tests/credential/profile_statistics.rs +++ b/backend/crates/providers/openai/tests/credential/profile_statistics.rs @@ -26,6 +26,7 @@ fn wire_profile() -> CodexWireProfileState { terminal: "profile-statistics-contract".to_owned(), exact_user_agent: None, residency: None, + account_routing_override: None, verified_at: Utc .with_ymd_and_hms(2026, 8, 28, 0, 0, 0) .single() diff --git a/backend/crates/providers/openai/tests/credential/quota/mod.rs b/backend/crates/providers/openai/tests/credential/quota/mod.rs index 6d5dc9dec..d6e4d6fa7 100644 --- a/backend/crates/providers/openai/tests/credential/quota/mod.rs +++ b/backend/crates/providers/openai/tests/credential/quota/mod.rs @@ -46,6 +46,7 @@ fn wire_profile() -> CodexWireProfileState { terminal: "quota-contract".to_owned(), exact_user_agent: None, residency: None, + account_routing_override: None, verified_at: Utc .with_ymd_and_hms(2026, 7, 18, 0, 0, 0) .single() diff --git a/backend/crates/providers/openai/tests/provider/contract/mod.rs b/backend/crates/providers/openai/tests/provider/contract/mod.rs index 69c174fb7..e82f0ee9b 100644 --- a/backend/crates/providers/openai/tests/provider/contract/mod.rs +++ b/backend/crates/providers/openai/tests/provider/contract/mod.rs @@ -1113,6 +1113,7 @@ fn wire_profile() -> CodexWireProfileState { terminal: "provider-contract".to_owned(), exact_user_agent: None, residency: None, + account_routing_override: None, verified_at: Utc::now(), }) } diff --git a/backend/crates/providers/openai/tests/provider/live.rs b/backend/crates/providers/openai/tests/provider/live.rs index aad39f4a7..612bc087b 100644 --- a/backend/crates/providers/openai/tests/provider/live.rs +++ b/backend/crates/providers/openai/tests/provider/live.rs @@ -604,14 +604,14 @@ async fn verify_live_call_identity() { }; use gateway_core::live::LiveHangupRequest; use provider_openai::credential::{CodexCredentialAdmin, ImportCodexOAuthCredential}; - use provider_openai::transport::profile::{CodexResidency, CodexWireProfile}; + use provider_openai::transport::profile::CodexWireProfile; use tokio::io::AsyncWriteExt; use tokio::net::TcpListener; let profile = CodexWireProfile { originator: "codex_cli_rs".to_owned(), exact_user_agent: Some("codex_cli_rs/0.160.0 (Linux; x86_64) live-test".to_owned()), codex_version: "0.160.0".to_owned(), - residency: Some(CodexResidency::Us), + residency: Some("us".to_owned()), ..CodexWireProfile::default() }; let opaque_profile = |profile: &CodexWireProfile| { diff --git a/backend/crates/providers/openai/tests/transport/catalog.rs b/backend/crates/providers/openai/tests/transport/catalog.rs index 1c21f4500..8ac62109b 100644 --- a/backend/crates/providers/openai/tests/transport/catalog.rs +++ b/backend/crates/providers/openai/tests/transport/catalog.rs @@ -427,6 +427,7 @@ fn profile() -> CodexWireProfileState { terminal: "xterm".to_owned(), exact_user_agent: None, residency: None, + account_routing_override: None, verified_at: Utc .with_ymd_and_hms(2026, 7, 18, 0, 0, 0) .single() diff --git a/backend/crates/providers/openai/tests/transport/mod.rs b/backend/crates/providers/openai/tests/transport/mod.rs index 5594fe1c9..8b19e0421 100644 --- a/backend/crates/providers/openai/tests/transport/mod.rs +++ b/backend/crates/providers/openai/tests/transport/mod.rs @@ -140,6 +140,7 @@ fn test_wire_profile() -> CodexWireProfileState { terminal: "transport-test".to_owned(), exact_user_agent: None, residency: None, + account_routing_override: None, verified_at: Utc::now(), }) } diff --git a/backend/crates/providers/openai/tests/transport/profile/identity.rs b/backend/crates/providers/openai/tests/transport/profile/identity.rs index cebbc5276..71edc1689 100644 --- a/backend/crates/providers/openai/tests/transport/profile/identity.rs +++ b/backend/crates/providers/openai/tests/transport/profile/identity.rs @@ -4,9 +4,7 @@ use gateway_core::account::OpaqueProviderData; use provider_openai::transport::headers::build_codex_model_headers; use provider_openai::transport::profile::identity::RequestProfileSelection; use provider_openai::transport::profile::selection::ClientProfileSelection; -use provider_openai::transport::profile::{ - CodexResidency, CodexWireProfile, CodexWireProfileState, -}; +use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState}; use serde_json::{Value, json}; fn document(value: Value) -> OpaqueProviderData { @@ -16,7 +14,8 @@ fn document(value: Value) -> OpaqueProviderData { #[test] fn complete_custom_identities_send_exact_user_agent_and_matching_companion_headers() { let state = CodexWireProfileState::new(CodexWireProfile { - residency: Some(CodexResidency::Us), + residency: Some("us".to_owned()), + account_routing_override: Some("us_cr".to_owned()), ..Default::default() }); for (core, originator, user_agent) in [ @@ -48,6 +47,7 @@ fn complete_custom_identities_send_exact_user_agent_and_matching_companion_heade assert_eq!(headers["originator"], originator); assert_eq!(headers["version"], core); assert_eq!(headers["x-openai-internal-codex-residency"], "us"); + assert_eq!(headers["x-openai-account-routing-override"], "us_cr"); assert!(profile.desktop_build.is_empty()); let frozen: CodexWireProfile = serde_json::from_value(serde_json::to_value(&profile).unwrap()).unwrap(); diff --git a/backend/crates/providers/openai/tests/transport/profile/mod.rs b/backend/crates/providers/openai/tests/transport/profile/mod.rs index bbcc93fb1..696cb154e 100644 --- a/backend/crates/providers/openai/tests/transport/profile/mod.rs +++ b/backend/crates/providers/openai/tests/transport/profile/mod.rs @@ -153,6 +153,7 @@ fn wire_profile_should_generate_bundled_core_app_server_user_agent() { terminal: "unknown".to_owned(), exact_user_agent: None, residency: None, + account_routing_override: None, verified_at: Utc .with_ymd_and_hms(2026, 8, 3, 0, 0, 0) .single() @@ -501,6 +502,7 @@ fn wire_profile() -> CodexWireProfile { terminal: "unknown".to_owned(), exact_user_agent: None, residency: None, + account_routing_override: None, verified_at: Utc .with_ymd_and_hms(2026, 8, 3, 0, 0, 0) .single() diff --git a/backend/crates/providers/openai/tests/transport/profile_contract.rs b/backend/crates/providers/openai/tests/transport/profile_contract.rs index 86491355a..dd8945536 100644 --- a/backend/crates/providers/openai/tests/transport/profile_contract.rs +++ b/backend/crates/providers/openai/tests/transport/profile_contract.rs @@ -1,8 +1,7 @@ -//! 验证各端点共用当前客户端画像并保留显式驻留配置 +//! 验证各端点共用当前客户端画像并保留部署指定的驻留与路由请求头 use super::*; use provider_openai::transport::build_reqwest_client; -use provider_openai::transport::profile::CodexResidency; use uuid::Uuid; use wiremock::{Mock, MockServer, ResponseTemplate, matchers::any}; @@ -109,9 +108,10 @@ async fn each_core_endpoint_uses_the_current_bundled_release() { } #[test] -fn residency_is_explicit_and_survives_artifact_updates() { +fn deployment_routing_survives_artifact_updates_and_profile_selection() { let mut initial = test_wire_profile().snapshot(); - initial.residency = Some(CodexResidency::Us); + initial.residency = Some("us".to_owned()); + initial.account_routing_override = Some("custom-route".to_owned()); let profile = CodexWireProfileState::new(initial); profile.update_bundled_release(&CodexBundledReleaseProfile { codex_version: "2.0.0".to_owned(), @@ -119,7 +119,10 @@ fn residency_is_explicit_and_survives_artifact_updates() { desktop_build: "9999".to_owned(), verified_at: Utc::now(), }); - let snapshot = profile.snapshot(); + let snapshot = + provider_openai::transport::profile::selection::ClientProfileSelection::default() + .resolve(&profile) + .expect("selected profile"); let model = provider_openai::transport::headers::build_codex_model_headers( &snapshot, "Bearer audit", @@ -133,8 +136,145 @@ fn residency_is_explicit_and_survives_artifact_updates() { ) .expect("account headers"); assert_eq!(model["x-openai-internal-codex-residency"], "us"); + assert_eq!(model["x-openai-account-routing-override"], "custom-route"); assert_eq!(model["version"], "2.0.0"); assert!(!account.contains_key("x-openai-internal-codex-residency")); + assert!(!account.contains_key("x-openai-account-routing-override")); +} + +#[tokio::test] +async fn model_transports_apply_configured_routing_and_discard_downstream_values() { + use base64::{Engine as _, engine::general_purpose::STANDARD}; + use gateway_core::operation::{GenerateRequest, ProtocolPayload}; + + const HEADERS: [&str; 2] = [ + "x-openai-internal-codex-residency", + "x-openai-account-routing-override", + ]; + for (document, expected) in [ + (r#"{}"#, [None, None]), + ( + r#"{"residency":null,"account_routing_override":null}"#, + [None, None], + ), + ( + r#"{"residency":"us","account_routing_override":"us"}"#, + [Some("us"), Some("us")], + ), + ( + r#"{"account_routing_override":"us_cr"}"#, + [None, Some("us_cr")], + ), + ( + r#"{"residency":"custom-region"}"#, + [Some("custom-region"), None], + ), + ( + r#"{"residency":"","account_routing_override":"custom-route"}"#, + [Some(""), Some("custom-route")], + ), + ( + r#"{"account_routing_override":"NO_CONSTRAINT"}"#, + [None, Some("NO_CONSTRAINT")], + ), + ] { + for websocket in [false, true] { + let (base_url, capture): ( + String, + futures::future::BoxFuture<'static, reqwest::header::HeaderMap>, + ) = if websocket { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let task = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut headers = None; + let mut socket = accept_codex_test_websocket_with(stream, |request, _| { + headers = Some(request.headers().clone()); + }) + .await; + socket.next().await.unwrap().unwrap(); + socket + .send(Message::Text( + completed_websocket_response("resp_routing", 1, 1).into(), + )) + .await + .unwrap(); + headers.unwrap() + }); + (base_url, Box::pin(async { task.await.unwrap() })) + } else { + let server = MockServer::start().await; + Mock::given(any()) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(format!( + "event: response.completed\ndata: {}\n\n", + completed_websocket_response("resp_routing", 1, 1), + )), + ) + .expect(1) + .mount(&server) + .await; + ( + server.uri(), + Box::pin(async move { + server.received_requests().await.unwrap()[0].headers.clone() + }), + ) + }; + let config: provider_openai::config::OpenAiConfig = + serde_json::from_str(document).unwrap(); + let profile = CodexWireProfileState::new(CodexWireProfile { + residency: config.residency, + account_routing_override: config.account_routing_override, + ..test_wire_profile().snapshot() + }); + let client = CodexBackendClient::new( + reqwest::Client::builder().no_proxy().build().unwrap(), + base_url, + profile, + ); + let downstream_headers = HEADERS + .into_iter() + .flat_map(|name| { + ["downstream-first", "downstream-second"] + .map(|value| json!([name, STANDARD.encode(value)])) + }) + .collect::>(); + let payload = ProtocolPayload::json_object( + "openai", + codex_request_body("gpt-test", "", Vec::new()), + ) + .unwrap() + .with_context(Map::from_iter([ + ("opaque_request_headers".into(), json!(downstream_headers)), + ("use_websocket".into(), json!(websocket)), + ])); + let request = provider_openai::encode_generate_request( + &GenerateRequest::from_protocol_payload(payload), + "gpt-test", + None, + ) + .unwrap(); + client + .create_response(&request, request_context("req_routing", Some("account"))) + .await + .expect("model response"); + let headers = capture.await; + for (name, value) in HEADERS.into_iter().zip(expected) { + assert_eq!( + headers + .get_all(name) + .iter() + .map(|value| value.to_str().unwrap()) + .collect::>(), + value.into_iter().collect::>(), + "{document}, websocket={websocket}, {name}", + ); + } + } + } } #[tokio::test] diff --git a/backend/crates/providers/openai/tests/transport/reset_credits.rs b/backend/crates/providers/openai/tests/transport/reset_credits.rs index 609540406..3cede4271 100644 --- a/backend/crates/providers/openai/tests/transport/reset_credits.rs +++ b/backend/crates/providers/openai/tests/transport/reset_credits.rs @@ -32,6 +32,7 @@ fn client(base_url: &str) -> CodexBackendClient { terminal: "xterm-256color".to_owned(), exact_user_agent: None, residency: None, + account_routing_override: None, verified_at: Utc .with_ymd_and_hms(2026, 8, 20, 0, 0, 0) .single() diff --git a/backend/crates/providers/openai/tests/transport/usage.rs b/backend/crates/providers/openai/tests/transport/usage.rs index 5b8b835c1..ef02236ad 100644 --- a/backend/crates/providers/openai/tests/transport/usage.rs +++ b/backend/crates/providers/openai/tests/transport/usage.rs @@ -885,6 +885,7 @@ fn profile() -> CodexWireProfileState { terminal: "xterm".to_owned(), exact_user_agent: None, residency: None, + account_routing_override: None, verified_at: Utc .with_ymd_and_hms(2026, 7, 18, 0, 0, 0) .single() diff --git a/backend/crates/providers/openai/tests/transport/websocket_pool.rs b/backend/crates/providers/openai/tests/transport/websocket_pool.rs index e2c6ba05f..943fd738b 100644 --- a/backend/crates/providers/openai/tests/transport/websocket_pool.rs +++ b/backend/crates/providers/openai/tests/transport/websocket_pool.rs @@ -2152,8 +2152,24 @@ async fn guardian_header_should_open_a_distinct_websocket() { #[tokio::test] async fn residency_profile_should_open_a_distinct_websocket() { - assert_changed_handshake_opens_distinct_websocket("x-openai-internal-codex-residency", "us") + for value in ["us", ""] { + assert_changed_handshake_opens_distinct_websocket( + "x-openai-internal-codex-residency", + value, + ) .await; + } +} + +#[tokio::test] +async fn account_routing_profile_should_open_a_distinct_websocket() { + for value in ["us_cr", ""] { + assert_changed_handshake_opens_distinct_websocket( + "x-openai-account-routing-override", + value, + ) + .await; + } } async fn assert_changed_handshake_opens_distinct_websocket( @@ -2227,9 +2243,15 @@ async fn assert_changed_handshake_opens_distinct_websocket( ) .await .unwrap(); - let backend = if header == "x-openai-internal-codex-residency" { + let backend = if header != "x-codex-guardian" { let mut profile = test_wire_profile().snapshot(); - profile.residency = Some(provider_openai::transport::profile::CodexResidency::Us); + match header { + "x-openai-internal-codex-residency" => profile.residency = Some(value.to_owned()), + "x-openai-account-routing-override" => { + profile.account_routing_override = Some(value.to_owned()) + } + _ => unreachable!("unknown profile header"), + } backend.with_request_profile(profile) } else { backend diff --git a/deploy/README.md b/deploy/README.md index 431ad5464..a393260b3 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -231,7 +231,10 @@ PostgreSQL/Redis 启动密码。日常校验使用 `config --quiet` | 代理位置 | “代理管理”;优先于全局位置,全局关闭时仍可独立生效 | 位置覆盖只影响 OpenAI 请求中受支持的位置与时区信息,不改变上下文日期、真实出口 IP、epoch 时间戳或系统时区。 -`openai.residency` 是独立的部署约束。身份与位置字段见 [运行设置 API](../docs/api.md#8-运行设置)。 +`openai.residency` 和 `openai.account_routing_override` 是独立于客户端身份的启动配置, +分别指定 `x-openai-internal-codex-residency` 和 `x-openai-account-routing-override` 请求头。 +两项均为可选字符串,填写值原样发送,不校验地域或路由枚举;省略或设为 `null` 时删除对应的下游请求头且不补发。 +示例配置均填写 `us`,修改后重启应用生效。身份与位置字段见 [运行设置 API](../docs/api.md#8-运行设置) 后台账号操作使用 Provider 自己的官方画像;发布资料刷新不改写用户选择或 `config.yaml` ### 批量维护代理 diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index fb97ad6d7..72821d8f0 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -78,8 +78,10 @@ api: request_id_header: 'x-request-id' openai: - # 数据驻留约束;省略或 null 不指定地域约束,与管理端客户端身份无关。 + # 数据驻留参考值:us;填写值原样发送,省略或 null 时删除对应请求头 residency: us + # 账号路由参考值:us、us_cr;填写值原样发送,省略或 null 时删除对应请求头 + account_routing_override: us # Compose 启动桥接区;密码仍只维护在 store 中。 services: diff --git a/docs/api.md b/docs/api.md index 9c5d74e44..fe8a4cd60 100644 --- a/docs/api.md +++ b/docs/api.md @@ -293,8 +293,10 @@ API Key 与 OAuth 共用模拟客户端画像(`User-Agent`、`originator`、`v 包括会话、线程、Lite 和其他业务扩展头。 上游认证只来自选中的账号;API Key 不携带 OAuth Cookie、ChatGPT 账号身份或下游的 `X-OpenAI-Actor-Authorization` 托管认证声明。 -下游的 `x-openai-account-routing-override`、`x-openai-fedramp` 也不透传, -工作区路由与合规属性不能从原账号继承。 +下游的 `x-openai-internal-codex-residency`、`x-openai-account-routing-override`、`x-openai-fedramp` +也不透传,地域、工作区路由与合规属性不能从原账号继承。 +前两项仅按部署配置 `openai.residency`、`openai.account_routing_override` 的字符串值生成, +省略或 `null` 时不发送,配置说明见 [部署与运维](../deploy/README.md#启动后的运行设置) 上述过滤描述原生转发;已信任插件的显式 header 改写可以覆盖账号与画像生成的默认头, 不按认证或会话字段名称拦截,边界见 [SDK 请求中间件](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#模型请求与-attempt)