mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix(openai): clear client headers when switching accounts
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -68,6 +68,14 @@ const ACCOUNT_BOUND_STATE_KEYS: &[&str] = &[
|
||||
|
||||
const TURN_METADATA_KEYS: &[&str] = &["turnMetadata", "turn_metadata", "x-codex-turn-metadata"];
|
||||
|
||||
const CROSS_ACCOUNT_CLIENT_HEADERS: &[&str] = &[
|
||||
"traceparent",
|
||||
"tracestate",
|
||||
"sentry-trace",
|
||||
"baggage",
|
||||
"x-openai-fedramp",
|
||||
];
|
||||
|
||||
const INSTALLATION_ID_KEYS: &[&str] = &[
|
||||
"installation_id",
|
||||
"installationId",
|
||||
@@ -471,6 +479,16 @@ pub(crate) fn scope_request_to_account(
|
||||
account_scope: RequestAccountScope,
|
||||
verified_turn_state: Option<&str>,
|
||||
) {
|
||||
if account_scope == RequestAccountScope::Different {
|
||||
// 明确换号时同步清理各 wire 副本,避免把原账号的追踪与合规上下文带给新账号
|
||||
for name in CROSS_ACCOUNT_CLIENT_HEADERS {
|
||||
request.passthrough_headers.remove(*name);
|
||||
}
|
||||
clear_cross_account_client_metadata(request.body_mut());
|
||||
if let Some(raw) = &mut request.turn_metadata {
|
||||
clear_cross_account_turn_metadata(raw);
|
||||
}
|
||||
}
|
||||
let reset_account_state = !account_scope.can_reuse_account_state();
|
||||
let client_metadata_turn_state = metadata_string(request, "x-codex-turn-state");
|
||||
let turn_state = if reset_account_state {
|
||||
@@ -584,6 +602,39 @@ pub(crate) fn scope_request_to_account(
|
||||
request.turn_metadata = turn_metadata;
|
||||
}
|
||||
|
||||
fn clear_cross_account_client_metadata(metadata: &mut Map<String, Value>) -> bool {
|
||||
let original_len = metadata.len();
|
||||
metadata.retain(|key, _| {
|
||||
let name = key.strip_prefix("ws_request_header_").unwrap_or(key);
|
||||
!CROSS_ACCOUNT_CLIENT_HEADERS.contains(&name)
|
||||
});
|
||||
let mut changed = metadata.len() != original_len;
|
||||
// 只处理协议 metadata 容器,业务输入和工具结果保持原始内容
|
||||
for key in TURN_METADATA_KEYS
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(["client_metadata"])
|
||||
{
|
||||
match metadata.get_mut(key) {
|
||||
Some(Value::Object(value)) => changed |= clear_cross_account_client_metadata(value),
|
||||
Some(Value::String(raw)) => changed |= clear_cross_account_turn_metadata(raw),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn clear_cross_account_turn_metadata(raw: &mut String) -> bool {
|
||||
if let Ok(Value::Object(mut metadata)) = serde_json::from_str(raw)
|
||||
&& clear_cross_account_client_metadata(&mut metadata)
|
||||
&& let Some(encoded) = serialize_ascii_turn_metadata(&Value::Object(metadata))
|
||||
{
|
||||
*raw = encoded;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn metadata_string(request: &CodexResponsesRequest, key: &str) -> Option<String> {
|
||||
request
|
||||
.client_metadata()?
|
||||
|
||||
@@ -3,6 +3,25 @@
|
||||
use super::*;
|
||||
|
||||
const SELECTED_ACCOUNT: &str = "acct_scope_same";
|
||||
const CLIENT_ACCOUNT_HEADERS: &[(&str, &[&str])] = &[
|
||||
(
|
||||
"traceparent",
|
||||
&["00-11111111111111111111111111111111-2222222222222222-01"],
|
||||
),
|
||||
("tracestate", &["vendor=client"]),
|
||||
("sentry-trace", &["client-trace"]),
|
||||
("baggage", &["environment=synthetic", "release=synthetic"]),
|
||||
("x-openai-fedramp", &["true", "false"]),
|
||||
];
|
||||
|
||||
fn client_account_metadata() -> Map<String, Value> {
|
||||
let mut metadata = Map::new();
|
||||
for (name, values) in CLIENT_ACCOUNT_HEADERS {
|
||||
metadata.insert((*name).to_owned(), json!(values[0]));
|
||||
metadata.insert(format!("ws_request_header_{name}"), json!(values[0]));
|
||||
}
|
||||
metadata
|
||||
}
|
||||
|
||||
pub(super) fn isolation_history() -> Value {
|
||||
json!([
|
||||
@@ -25,38 +44,54 @@ pub(super) fn isolation_history() -> Value {
|
||||
}
|
||||
|
||||
fn isolation_payload() -> ProtocolPayload {
|
||||
ProtocolPayload::json_object(
|
||||
"openai",
|
||||
json!({
|
||||
"model": "gpt-5.4",
|
||||
"input": isolation_history(),
|
||||
"client_metadata": {
|
||||
"parent_response_id": "resp_parent_old",
|
||||
"guardian_credits_requested": "true",
|
||||
"x-codex-turn-metadata": r#"{"parent_response_id":"client-correlation"}"#,
|
||||
"mcp_attribution": r#"{"status":"complete","sources":[]}"#
|
||||
}
|
||||
})
|
||||
.as_object()
|
||||
let mut turn_metadata = client_account_metadata();
|
||||
turn_metadata.insert("parent_response_id".to_owned(), json!("client-correlation"));
|
||||
turn_metadata.insert("workspace".to_owned(), json!("/工作区"));
|
||||
turn_metadata.insert("future".to_owned(), json!({"traceparent":"business-value"}));
|
||||
let mut body = json!({
|
||||
"model": "gpt-5.4",
|
||||
"input": isolation_history(),
|
||||
"client_metadata": {
|
||||
"parent_response_id": "resp_parent_old",
|
||||
"guardian_credits_requested": "true",
|
||||
"x-codex-turn-metadata": Value::Object(turn_metadata.clone()).to_string(),
|
||||
"turnMetadata": turn_metadata,
|
||||
"mcp_attribution": r#"{"status":"complete","sources":[]}"#
|
||||
}
|
||||
});
|
||||
body.as_object_mut()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
)
|
||||
.unwrap()
|
||||
.with_context(Map::from_iter([(
|
||||
"opaque_request_headers".to_owned(),
|
||||
.extend(client_account_metadata());
|
||||
body["client_metadata"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.extend(client_account_metadata());
|
||||
body["turnMetadata"] = json!(turn_metadata);
|
||||
body["turn_metadata"] = json!(Value::Object(turn_metadata.clone()).to_string());
|
||||
let mut headers = vec![
|
||||
json!(["X-OpenAI-Account-Routing-Override", STANDARD.encode("us")]),
|
||||
json!([
|
||||
["X-OpenAI-Account-Routing-Override", STANDARD.encode("us")],
|
||||
[
|
||||
"x-openai-account-routing-override",
|
||||
STANDARD.encode("us_cr")
|
||||
],
|
||||
["X-OpenAI-Fedramp", STANDARD.encode("true")],
|
||||
["x-openai-fedramp", STANDARD.encode("false")],
|
||||
["x-codex-guardian", STANDARD.encode("true")],
|
||||
["x-business-extension", STANDARD.encode("first")],
|
||||
["x-business-extension", STANDARD.encode("second")]
|
||||
"x-openai-account-routing-override",
|
||||
STANDARD.encode("us_cr")
|
||||
]),
|
||||
)]))
|
||||
json!(["x-codex-guardian", STANDARD.encode("true")]),
|
||||
json!(["x-business-extension", STANDARD.encode("first")]),
|
||||
json!(["x-business-extension", STANDARD.encode("second")]),
|
||||
];
|
||||
for (name, values) in CLIENT_ACCOUNT_HEADERS {
|
||||
for value in *values {
|
||||
headers.push(json!([name.to_ascii_uppercase(), STANDARD.encode(value)]));
|
||||
}
|
||||
}
|
||||
ProtocolPayload::json_object("openai", body.as_object().unwrap().clone())
|
||||
.unwrap()
|
||||
.with_context(Map::from_iter([
|
||||
("opaque_request_headers".to_owned(), json!(headers)),
|
||||
(
|
||||
"turn_metadata".to_owned(),
|
||||
json!(Value::Object(turn_metadata).to_string()),
|
||||
),
|
||||
]))
|
||||
}
|
||||
|
||||
fn isolation_context(owner: Option<&str>) -> AttemptContext {
|
||||
@@ -72,14 +107,24 @@ fn assert_isolated_request(
|
||||
owner: Option<&str>,
|
||||
) {
|
||||
assert!(!headers.contains_key("x-openai-account-routing-override"));
|
||||
assert_eq!(
|
||||
headers
|
||||
.get_all("x-openai-fedramp")
|
||||
let switched = owner.is_some_and(|owner| owner != SELECTED_ACCOUNT);
|
||||
for (name, values) in CLIENT_ACCOUNT_HEADERS {
|
||||
let actual: Vec<_> = headers
|
||||
.get_all(*name)
|
||||
.iter()
|
||||
.map(|value| value.to_str().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
["true", "false"]
|
||||
);
|
||||
.collect();
|
||||
let expected = if switched { &[][..] } else { *values };
|
||||
assert_eq!(actual, expected, "{name}, owner={owner:?}");
|
||||
for metadata in [body, &body["client_metadata"]] {
|
||||
let expected = (!switched).then(|| json!(values[0]));
|
||||
assert_eq!(metadata.get(*name), expected.as_ref());
|
||||
assert_eq!(
|
||||
metadata.get(format!("ws_request_header_{name}")),
|
||||
expected.as_ref()
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(headers["x-codex-guardian"], "true");
|
||||
assert_eq!(
|
||||
headers
|
||||
@@ -98,14 +143,31 @@ fn assert_isolated_request(
|
||||
body["client_metadata"]["guardian_credits_requested"],
|
||||
"true"
|
||||
);
|
||||
let mut expected_turn_metadata = if switched {
|
||||
Map::new()
|
||||
} else {
|
||||
client_account_metadata()
|
||||
};
|
||||
expected_turn_metadata.insert("parent_response_id".to_owned(), json!("client-correlation"));
|
||||
expected_turn_metadata.insert("workspace".to_owned(), json!("/工作区"));
|
||||
expected_turn_metadata.insert("future".to_owned(), json!({"traceparent":"business-value"}));
|
||||
for metadata in [
|
||||
&body["turnMetadata"],
|
||||
&body["turn_metadata"],
|
||||
&body["client_metadata"]["turnMetadata"],
|
||||
&body["client_metadata"]["x-codex-turn-metadata"],
|
||||
] {
|
||||
let actual = match metadata {
|
||||
Value::String(raw) => serde_json::from_str(raw).unwrap(),
|
||||
value => value.clone(),
|
||||
};
|
||||
assert_eq!(actual, Value::Object(expected_turn_metadata.clone()));
|
||||
}
|
||||
let header = headers["x-codex-turn-metadata"].to_str().unwrap();
|
||||
assert!(header.is_ascii());
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(
|
||||
body["client_metadata"]["x-codex-turn-metadata"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
json!({"parent_response_id": "client-correlation"}),
|
||||
serde_json::from_str::<Value>(header).unwrap(),
|
||||
Value::Object(expected_turn_metadata)
|
||||
);
|
||||
assert_eq!(
|
||||
body["client_metadata"]["mcp_attribution"],
|
||||
@@ -115,7 +177,7 @@ fn assert_isolated_request(
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_applies_deployment_routing_and_only_scopes_guardian_parent_reference() {
|
||||
async fn http_scopes_client_headers_and_guardian_parent_reference_to_the_account() {
|
||||
for owner in [Some(SELECTED_ACCOUNT), Some("acct_scope_old"), None] {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, SELECTED_ACCOUNT).await;
|
||||
@@ -158,7 +220,7 @@ async fn http_applies_deployment_routing_and_only_scopes_guardian_parent_referen
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_applies_deployment_routing_and_only_scopes_guardian_parent_reference() {
|
||||
async fn websocket_scopes_client_headers_and_guardian_parent_reference_to_the_account() {
|
||||
for owner in [Some(SELECTED_ACCOUNT), Some("acct_scope_old"), None] {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, SELECTED_ACCOUNT).await;
|
||||
|
||||
+4
-3
@@ -399,7 +399,7 @@ API Key 与 OAuth 共用模拟客户端画像(`User-Agent`、`originator`、`v
|
||||
`x-openai-internal-codex-residency` 和 `x-openai-account-routing-override` 分别由部署配置
|
||||
`openai.residency`、`openai.account_routing_override` 控制,配置值原样发送并覆盖客户端同名头,
|
||||
省略或 `null` 时删除对应请求头。配置说明见 [部署与运维](../deploy/README.md#启动后的运行设置)
|
||||
`x-openai-fedramp` 按业务头透传,保留原始多值。
|
||||
`x-openai-fedramp` 默认按业务头透传并保留原始多值,Responses 明确换号时清理。
|
||||
上述过滤描述原生转发;已信任插件的显式 header 改写可以覆盖账号与画像生成的默认头,
|
||||
不按认证或会话字段名称拦截,边界见 [SDK 请求中间件](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#模型请求与-attempt)
|
||||
|
||||
@@ -410,8 +410,9 @@ Responses 也不透传 `x-stainless-*`、`Origin`、`Referer`、`sec-ch-ua*` 和
|
||||
两者同时存在时仍优先使用 `session-id`。正文中的 `client_metadata.session_id`、
|
||||
`prompt_cache_key` 不受这条请求头规则影响。
|
||||
`thread-id`、turn metadata 等 Codex 协议字段及未知业务扩展不受下游环境头过滤规则影响;
|
||||
`traceparent`、`tracestate`、`sentry-trace`、`baggage` 及 WS metadata 中对应的
|
||||
`ws_request_header_*` 副本保持原值
|
||||
Responses 明确换号时清理 `traceparent`、`tracestate`、`sentry-trace`、`baggage` 和
|
||||
`x-openai-fedramp`,同时清理正文、`client_metadata`、turn metadata 中的同名字段及
|
||||
`ws_request_header_*` 副本。同账号及归属未知时保留原值,业务输入和工具结果保持原始内容
|
||||
|
||||
会话、线程、窗口及父子线程引用在出站时按本次安装身份稳定映射为 UUID;
|
||||
默认使用账号安装身份,同账号保留引用关系,换账号使用不同标识。HTTP 请求头、正文 metadata、WS 副本及
|
||||
|
||||
@@ -520,7 +520,8 @@ OpenAI 模型目录用于发现与原生 Responses 账号资格筛选,OAuth
|
||||
|
||||
- OpenAI 是透明边界。正文兼容、身份归一和显式请求策略之外的业务字段保留原始语义;
|
||||
业务响应按原始字节转发,客户端错误兼容与原生续写额度恢复按明确例外处理,见 [OpenAI 透传](api.md#openai-透传)。
|
||||
`transport/correlation` 在出站副本上按本次安装身份统一映射关联标识,客户端 trace 原样保留。
|
||||
`transport/correlation` 在出站副本上按本次安装身份统一映射关联标识。
|
||||
Responses 的账号归属判断及换号头部清理由 Provider 统一执行,各传输消费同一出站请求。
|
||||
原生安装身份取自账号;插件的身份生成、持久化及关联字段改写通过既有中间件链路自行完成。
|
||||
内部选号、亲和、连接池与本地诊断继续读取原始身份;具体字段边界见 [API 协议](api.md#请求头与身份)
|
||||
canonical facts 从同一数据旁路提取,用于路由、恢复判断、观测和计费。
|
||||
|
||||
Reference in New Issue
Block a user