feat(openai): configure residency and account routing headers (#515)

Accept optional deployment strings for residency and account routing,
discard downstream values when unset, and isolate WebSocket connections
by the configured handshake headers.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-25 23:19:11 +08:00
committed by GitHub
co-authored by Zyy Codex
parent 0a87ac7f76
commit 2c5b8b5c1a
26 changed files with 279 additions and 49 deletions
+21 -3
View File
@@ -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) {
@@ -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<CodexResidency>,
pub residency: Option<String>,
#[serde(default)]
pub account_routing_override: Option<String>,
#[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(),
}
}
+2 -1
View File
@@ -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 =
@@ -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");
@@ -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)
}
@@ -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,
}
}
@@ -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<String>,
/// 未配置时不发送 residency 头;不随制品版本更新而改变
pub residency: Option<CodexResidency>,
pub residency: Option<String>,
/// 部署指定的账号路由头;未配置时不发送,不随客户端身份选择改变
pub account_routing_override: Option<String>,
/// 版本元组最后一次经制品核验的时间;不表示 TLS 传输已重新核验
pub verified_at: DateTime<Utc>,
}
@@ -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),
}
@@ -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 {
@@ -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::<OpenAiConfig>(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]
@@ -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()
@@ -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");
@@ -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()
@@ -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()
@@ -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(),
})
}
@@ -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| {
@@ -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()
@@ -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(),
})
}
@@ -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();
@@ -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()
@@ -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::<Vec<_>>();
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::<Vec<_>>(),
value.into_iter().collect::<Vec<_>>(),
"{document}, websocket={websocket}, {name}",
);
}
}
}
}
#[tokio::test]
@@ -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()
@@ -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()
@@ -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
+4 -1
View File
@@ -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`
### 批量维护代理
+3 -1
View File
@@ -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:
+4 -2
View File
@@ -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)