diff --git a/backend/crates/gateway-api/src/openai/responses/request.rs b/backend/crates/gateway-api/src/openai/responses/request.rs index 1f5b32847..5710fa2b1 100644 --- a/backend/crates/gateway-api/src/openai/responses/request.rs +++ b/backend/crates/gateway-api/src/openai/responses/request.rs @@ -535,8 +535,6 @@ fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool { .iter() .any(|connection_header| connection_header.eq_ignore_ascii_case(name)) || is_transport_managed_request_header(name) - || name.starts_with("x-grok-") - || name.starts_with("x-xai-") { return false; } @@ -551,15 +549,9 @@ fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool { | "cookie" | "cookie2" | "chatgpt-account-id" - | "chatgpt-organization-id" - | "chatgpt-org-id" | "chatgpt-project-id" | "openai-organization" | "openai-project" - | "x-openai-organization" - | "x-openai-project" - // installation ID 始终由当前 lease 重建。 - | "x-codex-installation-id" // 上游指纹必须由运行时画像统一生成,客户端 originator/User-Agent/version // 不能作为不透明头透传覆盖,避免不同下游客户端暴露不一致的设备指纹。 | "originator" diff --git a/backend/crates/gateway-api/tests/openai/responses/http.rs b/backend/crates/gateway-api/tests/openai/responses/http.rs index 2499c15d2..1c0bf6066 100644 --- a/backend/crates/gateway-api/tests/openai/responses/http.rs +++ b/backend/crates/gateway-api/tests/openai/responses/http.rs @@ -15,6 +15,7 @@ use axum::{ header::{AUTHORIZATION, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE}, }, }; +use base64::{Engine as _, engine::general_purpose::STANDARD}; use bytes::Bytes; use futures::StreamExt; use futures::future::{BoxFuture, pending}; @@ -638,7 +639,11 @@ async fn request_context_should_resolve_forwarded_precedence_and_peer_fallback() input: Some(json!("hello")), client_metadata: None, // 客户端 User-Agent 仅用于本地展示,不再透传给上游指纹上下文。 - protocol_context: None, + protocol_context: Some(json!({"opaque_request_headers": [ + ["cf-connecting-ip", STANDARD.encode(b"198.51.100.1")], + ["x-real-ip", STANDARD.encode(b"198.51.100.2")], + ["x-forwarded-for", STANDARD.encode(b"10.0.0.2, 203.0.113.3")] + ]})), prompt_cache_key: None, previous_response_id: None, } @@ -662,6 +667,26 @@ async fn request_context_should_resolve_forwarded_precedence_and_peer_fallback() ); } +#[tokio::test] +async fn opaque_client_headers_should_not_change_local_client_observation() { + let mut headers = HeaderMap::new(); + headers.insert("user-agent", "pi/synthetic".parse().unwrap()); + headers.insert("x-stainless-runtime", "node".parse().unwrap()); + headers.insert("origin", "https://synthetic.invalid".parse().unwrap()); + let captured = captured_client_context(headers, "192.0.2.10:443".parse().unwrap()).await; + assert_eq!(captured.user_agent.as_deref(), Some("pi/synthetic")); + assert_eq!(captured.client_ip, Some("192.0.2.10".parse().unwrap())); + assert_eq!( + captured.protocol_context, + Some(json!({ + "opaque_request_headers": [ + ["x-stainless-runtime", STANDARD.encode(b"node")], + ["origin", STANDARD.encode(b"https://synthetic.invalid")] + ] + })) + ); +} + #[tokio::test] async fn stale_model_catalog_should_not_block_a_new_model_request() { let model = " gpt-future-codex "; @@ -768,7 +793,7 @@ async fn subagent_header_should_not_replace_non_object_client_metadata() { } #[tokio::test] -async fn xai_private_headers_should_not_enter_openai_request_facts() { +async fn xai_private_headers_should_remain_opaque_without_projecting_request_facts() { let peer = "192.0.2.10:443".parse().expect("peer address"); let mut headers = HeaderMap::new(); headers.insert("x-grok-turn-idx", "7".parse().expect("header")); @@ -776,7 +801,15 @@ async fn xai_private_headers_should_not_enter_openai_request_facts() { let captured = captured_client_context(headers, peer).await; - assert!(captured.protocol_context.is_none()); + assert_eq!( + captured.protocol_context, + Some(json!({ + "opaque_request_headers": [ + ["x-grok-turn-idx", STANDARD.encode(b"7")], + ["x-grok-conv-id", STANDARD.encode(b"private-session")] + ] + })) + ); assert!(captured.prompt_cache_key.is_none()); } diff --git a/backend/crates/gateway-api/tests/openai/responses/mod.rs b/backend/crates/gateway-api/tests/openai/responses/mod.rs index 42f850f5d..114f7e97b 100644 --- a/backend/crates/gateway-api/tests/openai/responses/mod.rs +++ b/backend/crates/gateway-api/tests/openai/responses/mod.rs @@ -233,6 +233,14 @@ fn decoder_should_preserve_connection_metadata_outside_the_openai_wire_body() { #[test] fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() { let mut headers = HeaderMap::new(); + for name in [ + "chatgpt-organization-id", + "chatgpt-org-id", + "x-openai-organization", + "x-openai-project", + ] { + headers.insert(name, HeaderValue::from_static("unclassified-extension")); + } headers.append( "x-openai-future-mode", HeaderValue::from_static("future-ascii"), @@ -338,6 +346,18 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() { values("openai-beta"), vec![b"future_responses=v2".to_vec(), b"future_tools=v3".to_vec()] ); + for name in [ + "chatgpt-organization-id", + "chatgpt-org-id", + "x-openai-organization", + "x-openai-project", + ] { + assert_eq!(values(name), vec![b"unclassified-extension".to_vec()]); + } + assert_eq!( + values("x-codex-installation-id"), + vec![b"client-installation".to_vec()] + ); for preserved in [ "accept", "content-type", @@ -357,7 +377,6 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() { "cookie", "chatgpt-account-id", "chatgpt-project-id", - "x-codex-installation-id", // 上游指纹由运行时画像统一生成,客户端不得覆盖。 "user-agent", "originator", @@ -371,7 +390,7 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() { } #[test] -fn decoder_should_exclude_downstream_transport_headers_from_opaque_context() { +fn decoder_should_strip_http_transport_but_leave_source_headers_for_provider() { let mut headers = HeaderMap::new(); for name in [ "cf-visitor", @@ -407,10 +426,124 @@ fn decoder_should_exclude_downstream_transport_headers_from_opaque_context() { assert_eq!( openai_protocol_context(&decoded).get("opaque_request_headers"), - Some(&json!([["x-openai-future-mode", STANDARD.encode(b"keep")]])), + Some(&Value::Array( + headers + .iter() + .filter(|(name, _)| !matches!( + name.as_str(), + "accept-encoding" | "content-encoding" + )) + .map(|(name, value)| json!([name.as_str(), STANDARD.encode(value.as_bytes())])) + .collect() + )), ); } +#[test] +fn downstream_client_headers_should_remain_opaque_without_losing_session_semantics() { + for canonical in [None, Some("canonical-session")] { + let mut headers = HeaderMap::new(); + for name in [ + "X-Stainless-Runtime", + "x-stainless-future-field", + "Origin", + "Referer", + "Sec-Ch-Ua", + "sec-ch-ua-platform", + "Sec-Fetch-Site", + "session_id", + ] { + headers.append(name, HeaderValue::from_static("alias-session")); + headers.append(name, HeaderValue::from_static("duplicate")); + } + if let Some(session) = canonical { + headers.insert("session-id", HeaderValue::from_static(session)); + } + for name in [ + "thread-id", + "x-client-request-id", + "traceparent", + "tracestate", + ] { + headers.insert(name, HeaderValue::from_static("keep")); + } + headers.append("x-future-business", HeaderValue::from_static("first")); + headers.append( + "x-future-business", + HeaderValue::from_bytes(b"\x80\xff").unwrap(), + ); + let original_headers = headers.clone(); + let body = json!({ + "model": "smart-code", "input": "中文 pi 内容不能被请求头过滤改写", + "prompt_cache_key": "synthetic-cache", + "client_metadata": {"session_id": "body-session"} + }); + let mut frame = body.clone(); + frame["type"] = json!("response.create"); + let opening = OpenAiRequestHeaders::from_headers(&headers); + for decoded in [ + decode_request_with_headers(body.to_string().as_bytes(), &headers).unwrap(), + decode_response_create_with_context(&frame.to_string(), &opening).unwrap(), + decode_response_create_with_context(&frame.to_string(), &opening).unwrap(), + ] { + assert_eq!(openai_wire_body(&decoded), body.as_object().unwrap()); + let context = openai_protocol_context(&decoded); + assert_eq!(context["session_id"], canonical.unwrap_or("alias-session")); + let entries = context["opaque_request_headers"].as_array().unwrap(); + for name in [ + "x-stainless-runtime", + "x-stainless-future-field", + "origin", + "referer", + "sec-ch-ua", + "sec-ch-ua-platform", + "sec-fetch-site", + "session_id", + ] { + let values: Vec<_> = entries + .iter() + .filter(|entry| entry[0] == name) + .cloned() + .collect(); + assert_eq!( + values, + vec![ + json!([name, STANDARD.encode(b"alias-session")]), + json!([name, STANDARD.encode(b"duplicate")]), + ], + "source header {name} belongs to the Provider" + ); + } + for name in [ + "thread-id", + "x-client-request-id", + "traceparent", + "tracestate", + ] { + assert!( + entries + .iter() + .any(|entry| entry == &json!([name, STANDARD.encode(b"keep")])) + ); + } + let future: Vec<_> = entries + .iter() + .filter(|entry| entry[0] == "x-future-business") + .cloned() + .collect(); + assert_eq!( + future, + vec![ + json!(["x-future-business", STANDARD.encode(b"first")]), + json!(["x-future-business", STANDARD.encode(b"\x80\xff")]), + ] + ); + } + // 解码不修改原始请求,CORS、鉴权和本地观测仍可读取原值。 + assert_eq!(headers, original_headers); + } +} + #[test] fn decoder_should_preserve_unknown_nested_values_without_debug_disclosure() { let secret = "nested-private-value"; diff --git a/backend/crates/gateway-api/tests/openai/responses/websocket/protocol.rs b/backend/crates/gateway-api/tests/openai/responses/websocket/protocol.rs index 69618d6b6..49b8027c1 100644 --- a/backend/crates/gateway-api/tests/openai/responses/websocket/protocol.rs +++ b/backend/crates/gateway-api/tests/openai/responses/websocket/protocol.rs @@ -7,7 +7,7 @@ use serde_json::json; use super::decode_response_create; #[test] -fn response_create_should_exclude_cloudflare_headers_on_every_frame() { +fn response_create_should_carry_source_headers_to_provider_on_every_frame() { let mut headers = HeaderMap::new(); for (name, value) in [ ("CF-Visitor", r#"{"scheme":"https"}"#), @@ -24,7 +24,7 @@ fn response_create_should_exclude_cloudflare_headers_on_every_frame() { headers.append("x-openai-future-mode", HeaderValue::from_static("second")); let request_headers = OpenAiRequestHeaders::from_headers(&headers); - // 连接级头会用于同一 WebSocket 的每一帧,后续帧也不能恢复下游链路元数据。 + // 连接级头随每一帧传给 Provider,由相同的出站边界处理来源适配。 for input in ["hello", "continue"] { let decoded = decode_response_create_with_context( &json!({"type": "response.create", "model": "smart-code", "input": input}).to_string(), @@ -39,10 +39,12 @@ fn response_create_should_exclude_cloudflare_headers_on_every_frame() { .protocol_payload() .context() .get("opaque_request_headers"), - Some(&json!([ - ["x-openai-future-mode", STANDARD.encode(b"first")], - ["x-openai-future-mode", STANDARD.encode(b"second")], - ])), + Some(&serde_json::Value::Array( + headers + .iter() + .map(|(name, value)| json!([name.as_str(), STANDARD.encode(value.as_bytes())])) + .collect() + )), ); assert_eq!(request.protocol_payload().body()["input"], input); } diff --git a/backend/crates/gateway-protocol/src/openai/headers.rs b/backend/crates/gateway-protocol/src/openai/headers.rs index aa8954716..6c773af74 100644 --- a/backend/crates/gateway-protocol/src/openai/headers.rs +++ b/backend/crates/gateway-protocol/src/openai/headers.rs @@ -1,4 +1,4 @@ -//! OpenAI 请求头的业务协议与传输边界。 +//! OpenAI 请求头的 HTTP 传输边界。 /// 判断小写请求头是否属于传输层管理的字段,不得作为业务扩展头透传。 /// @@ -6,10 +6,7 @@ /// 只用于请求方向,不影响上游响应中的代理诊断信息。 #[must_use] pub fn is_transport_managed_request_header(name: &str) -> bool { - // 代理命名空间描述的是下游链路;包括未知扩展也不能冒充上游连接事实。 - name.starts_with("cf-") - || name.starts_with("x-forwarded-") - || name.starts_with("sec-websocket-") + name.starts_with("sec-websocket-") || matches!( name, "connection" @@ -26,11 +23,7 @@ pub fn is_transport_managed_request_header(name: &str) -> bool { // 请求实体与响应压缩能力属于各段 transport,不能继承下游协商。 | "content-encoding" | "accept-encoding" - | "forwarded" - | "via" - | "cdn-loop" - | "x-real-ip" - | "true-client-ip" + // 网关中间件也会生成该链路诊断 ID,不作为业务上下文转发。 | "x-request-id" ) } diff --git a/backend/crates/gateway-protocol/tests/openai/headers.rs b/backend/crates/gateway-protocol/tests/openai/headers.rs index 57635d304..546c4a98b 100644 --- a/backend/crates/gateway-protocol/tests/openai/headers.rs +++ b/backend/crates/gateway-protocol/tests/openai/headers.rs @@ -1,7 +1,31 @@ use gateway_protocol::openai::is_transport_managed_request_header; #[test] -fn transport_headers_should_include_proxy_namespaces_and_compression() { +fn transport_headers_should_include_hop_fields_and_compression() { + for name in [ + "accept-encoding", + "content-encoding", + "content-length", + "host", + "x-request-id", + "connection", + "keep-alive", + "proxy-connection", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "sec-websocket-key", + "sec-websocket-extensions", + ] { + assert!(is_transport_managed_request_header(name), "missing {name}"); + } +} + +#[test] +fn transport_headers_should_leave_business_extensions_to_the_protocol_owner() { for name in [ "cf-visitor", "cf-connecting-ip", @@ -24,30 +48,6 @@ fn transport_headers_should_include_proxy_namespaces_and_compression() { "x-forwarded-future-field", "x-real-ip", "true-client-ip", - "x-request-id", - "accept-encoding", - "content-encoding", - "content-length", - "host", - "connection", - "keep-alive", - "proxy-connection", - "proxy-authenticate", - "proxy-authorization", - "te", - "trailer", - "transfer-encoding", - "upgrade", - "sec-websocket-key", - "sec-websocket-extensions", - ] { - assert!(is_transport_managed_request_header(name), "missing {name}"); - } -} - -#[test] -fn transport_headers_should_leave_business_extensions_to_the_protocol_owner() { - for name in [ "x-openai-future-mode", "x-custom-extension", "x-client-request-id", diff --git a/backend/crates/providers/openai/src/transport/downstream/body.rs b/backend/crates/providers/openai/src/transport/downstream/body.rs new file mode 100644 index 000000000..edb1a698d --- /dev/null +++ b/backend/crates/providers/openai/src/transport/downstream/body.rs @@ -0,0 +1,21 @@ +//! 下游客户端发送的非 Codex 模型请求参数兼容。 + +use serde_json::{Map, Value}; + +/// 仅移除已确认不适用于 Codex Responses 的顶层参数,不递归清洗业务正文。 +/// +/// 兼容基准是 Codex Core/Desktop 的模型请求,不是公开 OpenAI Responses API。 +/// 未知字段继续透传,不能因官方请求结构中没有某个字段就将其列入过滤规则。 +pub(in crate::transport) fn strip_non_codex_request_fields(body: &mut Map) { + for field in [ + // Pi 普通 Responses 适配将 maxTokens 映射为 max_output_tokens, + // temperature 则原样写入;Pi 的 Codex 适配也可能发送 temperature。 + "max_output_tokens", + "temperature", + // Pi 开启长缓存时发送 24h;保留有效的 prompt_cache_key, + // 只剥离 Codex Responses 明确拒绝的缓存保留时长参数。 + "prompt_cache_retention", + ] { + body.remove(field); + } +} diff --git a/backend/crates/providers/openai/src/transport/downstream/headers.rs b/backend/crates/providers/openai/src/transport/downstream/headers.rs new file mode 100644 index 000000000..44be61f85 --- /dev/null +++ b/backend/crates/providers/openai/src/transport/downstream/headers.rs @@ -0,0 +1,42 @@ +//! 已识别的非 Codex 环境信息过滤;官方身份和会话规则由 transport 自身维护。 + +/// 调用方须传入 HeaderName 规范化后的小写名称。 +/// +/// 此处的“官方”仅指 Codex Core/Desktop 发往 Codex 上游的请求协议, +/// 不包括整个 OpenAI SDK 生态。名单中的字段可能是合法 HTTP 字段, +/// 过滤表示网关不继承下游环境,不代表官方服务端必然拒绝该字段。 +/// 按已知来源命名空间覆盖扩展;其他未知业务头不因源码中未出现而被过滤。 +pub(in crate::transport) fn is_non_codex_request_header(name: &str) -> bool { + // Cloudflare 链路信息及 Access 认证不跨到上游;cf-* 不只包含诊断字段。 + name.starts_with("cf-") + // 反代记录的是客户端到网关这一段地址、协议和路由。 + || name.starts_with("x-forwarded-") + // OpenAI 官方 Python/Node SDK 也发送 X-Stainless-*;Pi 的普通 Responses + // 适配通过该 SDK 发送这些环境、版本和重试信息,不作为 Codex 上游画像继承。 + || name.starts_with("x-stainless-") + // UA Client Hints 与 Fetch Metadata 是浏览器标准,描述下游浏览器 + // 和页面请求上下文,不能当作网关连接上游时的环境。 + || name.starts_with("sec-ch-ua") + || name.starts_with("sec-fetch-") + // Grok 官方客户端的认证和路由头不属于 Codex 协议; + // 例如 x-grok-model-override 与 X-XAI-Token-Auth。 + || name.starts_with("x-grok-") + || name.starts_with("x-xai-") + || matches!( + name, + // Forwarded、Via、CDN-Loop 有 RFC 定义;剥离是本应用重建 + // Provider 请求的策略,不是通用 HTTP 代理的协议要求。 + "forwarded" + | "via" + | "cdn-loop" + // Nginx/代理与 Cloudflare 使用的原始访客地址。 + | "x-real-ip" + | "true-client-ip" + // 页面来源与引用地址属于下游请求,不继承到上游。 + | "origin" + | "referer" + // Pi 普通 Responses 适配使用的会话头别名;协议层已提取其 + // 会话语义,上游按 session-id 输出,这里仅过滤原始 HTTP 头。 + | "session_id" + ) +} diff --git a/backend/crates/providers/openai/src/transport/downstream/mod.rs b/backend/crates/providers/openai/src/transport/downstream/mod.rs new file mode 100644 index 000000000..d129022b3 --- /dev/null +++ b/backend/crates/providers/openai/src/transport/downstream/mod.rs @@ -0,0 +1,8 @@ +//! 下游客户端对 Codex Core/Desktop 请求协议的兼容处理。 +//! 账号身份保护、会话规范化和 HTTP 传输规则由对应职责模块维护。 + +mod body; +mod headers; + +pub(super) use body::strip_non_codex_request_fields; +pub(super) use headers::is_non_codex_request_header; diff --git a/backend/crates/providers/openai/src/transport/headers.rs b/backend/crates/providers/openai/src/transport/headers.rs index 964d0efe1..ca619fd4d 100644 --- a/backend/crates/providers/openai/src/transport/headers.rs +++ b/backend/crates/providers/openai/src/transport/headers.rs @@ -16,6 +16,23 @@ 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 { + matches!( + name, + "authorization" + | "x-api-key" + | "x-openai-actor-authorization" + | "cookie" + | "cookie2" + | "chatgpt-account-id" + | "chatgpt-project-id" + | "openai-organization" + | "openai-project" + ) +} + /// 构造 Codex Core 为模型请求设置的稳定身份请求头。 pub fn build_codex_model_headers( profile: &CodexWireProfile, @@ -231,7 +248,6 @@ fn append_passthrough_headers( | "content-type" | "content-encoding" | "x-codex-routing-hint" - | "x-codex-installation-id" | "x-codex-turn-id" | "x-oai-attestation" | "x-oai-is" diff --git a/backend/crates/providers/openai/src/transport/mod.rs b/backend/crates/providers/openai/src/transport/mod.rs index 767d04d01..d5bcaddb3 100644 --- a/backend/crates/providers/openai/src/transport/mod.rs +++ b/backend/crates/providers/openai/src/transport/mod.rs @@ -6,6 +6,7 @@ pub mod client; mod client_json; mod client_sse; pub mod diagnostics; +mod downstream; pub mod endpoints; pub mod headers; pub mod profile; diff --git a/backend/crates/providers/openai/src/transport/request.rs b/backend/crates/providers/openai/src/transport/request.rs index f93c65133..91bed43c2 100644 --- a/backend/crates/providers/openai/src/transport/request.rs +++ b/backend/crates/providers/openai/src/transport/request.rs @@ -14,6 +14,8 @@ use serde::Serialize as _; use serde_json::{Map, Value, json}; use sha2::{Digest, Sha256}; +use crate::transport::downstream::{is_non_codex_request_header, strip_non_codex_request_fields}; +use crate::transport::headers::is_managed_identity_header; use crate::transport::profile::CodexRequestLocation; use crate::transport::protocol::responses::{ CodexResponsesRequest, X_CODEX_TURN_STATE_CLIENT_METADATA_KEY, @@ -24,7 +26,6 @@ const TURN_ID_CLIENT_METADATA_KEY: &str = "turn_id"; const THREAD_SPAWN_SUBAGENT_KIND: &str = "thread_spawn"; const THREAD_SPAWN_CONVERSATION_PREFIX: &str = "thread-spawn:"; const ENVIRONMENT_CONTEXT_CONTENT_KIND: &str = "environments.environment_context"; -const UNSUPPORTED_CODEX_RESPONSES_FIELDS: &[&str] = &["max_output_tokens", "temperature"]; const CROSS_ACCOUNT_IDENTITY_KEYS: &[&str] = &[ "authorization", @@ -119,9 +120,7 @@ fn adapt_codex_responses_body( location: Option<&CodexRequestLocation>, ) { body.insert("model".to_owned(), Value::String(upstream_model.to_owned())); - for field in UNSUPPORTED_CODEX_RESPONSES_FIELDS { - body.remove(*field); - } + strip_non_codex_request_fields(body); if let Some(location) = location { align_structured_location_fields(body, Utc::now(), location); } @@ -454,8 +453,8 @@ fn normalize_conversation_anchor_text(text: &str) -> String { /// 把客户端正文收敛到当前 lease 的账号身份边界。 /// /// 真实 account ID 由随后构造的 `CodexRequestContext` 注入请求头;installation ID -/// 统一写入 Core client_metadata,并替换原有兼容字段,绝不接受客户端 -/// 提供的 token、cookie 或账号身份。`input` 是 Responses 的可回放会话正文, +/// 统一写入 Core 的 `client_metadata["x-codex-installation-id"]`,并替换原有兼容字段。 +/// 绝不接受客户端提供的 token、cookie 或账号身份。`input` 是 Responses 的可回放会话正文, /// item ID、encrypted content 和 compaction 都必须原样保留。 pub(crate) fn scope_request_to_account( request: &mut CodexResponsesRequest, @@ -527,13 +526,15 @@ pub(crate) fn scope_request_to_account( metadata.remove(*key); } } + // 官方 Core 在 client_metadata 使用带 x-codex 前缀的键, + // turn metadata 内仍使用 installation_id;两处均取当前账号的安装身份。 metadata.insert( - "installation_id".to_owned(), + "x-codex-installation-id".to_owned(), Value::String(installation_id.to_owned()), ); replace_existing_metadata_field( &mut metadata, - "x-codex-installation-id", + "installation_id", Some(installation_id), ); replace_existing_metadata_field(&mut metadata, "installationId", Some(installation_id)); @@ -738,7 +739,10 @@ fn decode_passthrough_headers(context: &Map) -> HeaderMap { let Ok(name) = HeaderName::from_bytes(name.as_bytes()) else { continue; }; - if provider_managed_header(name.as_str()) { + if is_transport_managed_request_header(name.as_str()) + || is_managed_identity_header(name.as_str()) + || is_non_codex_request_header(name.as_str()) + { continue; } let Some(encoded) = entry.get(1).and_then(Value::as_str) else { @@ -755,29 +759,6 @@ fn decode_passthrough_headers(context: &Map) -> HeaderMap { headers } -fn provider_managed_header(name: &str) -> bool { - is_transport_managed_request_header(name) - || name.starts_with("x-grok-") - || name.starts_with("x-xai-") - || matches!( - name, - "authorization" - | "x-api-key" - | "x-openai-actor-authorization" - | "cookie" - | "cookie2" - | "chatgpt-account-id" - | "chatgpt-organization-id" - | "chatgpt-org-id" - | "chatgpt-project-id" - | "openai-organization" - | "openai-project" - | "x-openai-organization" - | "x-openai-project" - | "x-codex-installation-id" - ) -} - fn context_string(context: &Map, field: &str) -> Option { context .get(field) diff --git a/backend/crates/providers/openai/tests/provider/contract.rs b/backend/crates/providers/openai/tests/provider/contract.rs index 22906064d..2d4b12636 100644 --- a/backend/crates/providers/openai/tests/provider/contract.rs +++ b/backend/crates/providers/openai/tests/provider/contract.rs @@ -3618,12 +3618,9 @@ async fn same_account_scope_preserves_future_protocol_shapes() { ); assert_eq!( body.get("installation_id"), - body.pointer("/client_metadata/installation_id") - ); - assert!( body.pointer("/client_metadata/x-codex-installation-id") - .is_none() ); + assert!(body.pointer("/client_metadata/installation_id").is_none()); assert!(captured_header_values(&request, "x-codex-installation-id").is_empty()); } @@ -3771,7 +3768,7 @@ async fn websocket_account_scoping_preserves_ascii_turn_metadata_and_unicode_inp }).to_string().into())).await.expect("complete response"); body }); - // Official Codex keeps embedded turn metadata ASCII even for Unicode workspaces. + // 官方 Codex 在工作区包含 Unicode 时也保持内嵌 turn metadata 为 ASCII。 let raw = r#"{"installation_id":"client-installation","workspaces":{"C:\\Users\\\u9879\u76ee\\\ud83d\ude80":{"label":"caf\u00e9","literal":"\\u4e2d","quoted":"\"line\n"}}}"#; let input = json!([{"role": "user", "content": "中文正文 🚀"}]); let payload = ProtocolPayload::json_object( @@ -3781,7 +3778,12 @@ async fn websocket_account_scoping_preserves_ascii_turn_metadata_and_unicode_inp ("input".to_owned(), input.clone()), ( "client_metadata".to_owned(), - json!({"x-codex-turn-metadata": raw}), + json!({ + "x-codex-turn-metadata": raw, + "x-codex-installation-id": "client-installation", + "installation_id": "client-legacy-installation", + "installationId": "client-camel-installation" + }), ), ]), ) @@ -3801,13 +3803,20 @@ async fn websocket_account_scoping_preserves_ascii_turn_metadata_and_unicode_inp event.expect("successful websocket response"); } let body = server.await.expect("server"); + let installation_id = body["client_metadata"]["x-codex-installation-id"] + .as_str() + .expect("account installation ID"); + assert_ne!(installation_id, "client-installation"); + for alias in ["installation_id", "installationId"] { + assert_eq!(body["client_metadata"][alias], installation_id); + } let encoded = body .pointer("/client_metadata/x-codex-turn-metadata") .and_then(Value::as_str) .expect("turn metadata"); assert!(encoded.is_ascii(), "embedded header JSON must remain ASCII"); let mut expected: Value = serde_json::from_str(raw).expect("original metadata"); - expected["installation_id"] = body["client_metadata"]["installation_id"].clone(); + expected["installation_id"] = json!(installation_id); assert_eq!( serde_json::from_str::(encoded).expect("metadata JSON"), expected @@ -3836,10 +3845,16 @@ async fn http_account_scoping_keeps_unicode_metadata_ascii_in_headers_and_body() ) .await; let body = captured_request_body(&request); + let installation_id = body["client_metadata"]["x-codex-installation-id"] + .as_str() + .expect("account installation ID"); + assert_ne!(installation_id, "client-installation"); + assert!(body.pointer("/client_metadata/installation_id").is_none()); + assert!(body.pointer("/client_metadata/installationId").is_none()); let headers = captured_header_values(&request, "x-codex-turn-metadata"); assert_eq!(headers.len(), 1); let mut expected: Value = serde_json::from_str(raw).expect("original metadata"); - expected["installation_id"] = body["client_metadata"]["installation_id"].clone(); + expected["installation_id"] = json!(installation_id); for encoded in [ std::str::from_utf8(&headers[0]).expect("UTF-8 header"), body["turnMetadata"].as_str().expect("body turn metadata"), @@ -7527,6 +7542,48 @@ async fn connection_limit_payload_survives_exhausted_retry_budget() { ); } +const API_KEY_DOWNSTREAM_HEADERS: &[&str] = &[ + "cf-future-proxy-field", + "x-forwarded-for", + "x-stainless-runtime", + "origin", + "referer", + "sec-ch-ua", + "sec-fetch-site", + "x-grok-turn-idx", + "x-xai-future-field", + "session_id", + "session-id", + "thread-id", + "x-codex-future", + "x-openai-internal-future", + "authorization", + "cookie", + "chatgpt-account-id", +]; + +fn generate_with_downstream_headers() -> Operation { + let mut headers: Vec<_> = API_KEY_DOWNSTREAM_HEADERS + .iter() + .map(|name| json!([name, STANDARD.encode(b"downstream-value")])) + .collect(); + headers.push(json!(["x-business-extension", STANDARD.encode(b"keep")])); + Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "openai", + json!({"model": "gpt-5.4", "input": "hello"}) + .as_object() + .unwrap() + .clone(), + ) + .unwrap() + .with_context(Map::from_iter([( + "opaque_request_headers".to_owned(), + json!(headers), + )])), + )) +} + #[tokio::test] async fn api_key_default_http_uses_own_prefix_plain_json_and_only_own_authentication() { for prefix in ["", "/v1", "/custom/v2"] { @@ -7581,7 +7638,7 @@ async fn api_key_default_http_uses_own_prefix_plain_json_and_only_own_authentica )); let mut stream = provider .execute( - planned_request("openai", generate_operation()), + planned_request("openai", generate_with_downstream_headers()), context("req_api_http", CancellationToken::new()), ) .await @@ -7597,6 +7654,13 @@ async fn api_key_default_http_uses_own_prefix_plain_json_and_only_own_authentica .find(|request| request.method == "POST") .unwrap(); assert!(!request.headers.contains_key("content-encoding")); + for name in API_KEY_DOWNSTREAM_HEADERS + .iter() + .filter(|name| **name != "authorization") + { + assert!(!request.headers.contains_key(*name), "leaked {name}"); + } + assert_eq!(request.headers["x-business-extension"], "keep"); for header in [ "cookie", "chatgpt-account-id", @@ -7771,6 +7835,13 @@ async fn api_key_websocket_uses_api_path_and_bearer_without_oauth_identity() { assert!(!request.headers().contains_key("chatgpt-account-id")); assert!(!request.headers().contains_key("cookie")); assert!(!request.headers().contains_key("originator")); + for name in API_KEY_DOWNSTREAM_HEADERS + .iter() + .filter(|name| **name != "authorization") + { + assert!(!request.headers().contains_key(*name), "leaked {name}"); + } + assert_eq!(request.headers()["x-business-extension"], "keep"); }) .await; let frame = websocket.next().await.unwrap().unwrap(); @@ -7781,7 +7852,7 @@ async fn api_key_websocket_uses_api_path_and_bearer_without_oauth_identity() { let provider = provider(&store); let mut stream = provider .execute( - planned_request("openai", generate_operation()), + planned_request("openai", generate_with_downstream_headers()), diagnostic_context("req_api_ws", "acct_provider_contract"), ) .await diff --git a/backend/crates/providers/openai/tests/transport/downstream/body.rs b/backend/crates/providers/openai/tests/transport/downstream/body.rs new file mode 100644 index 000000000..b8983f7f1 --- /dev/null +++ b/backend/crates/providers/openai/tests/transport/downstream/body.rs @@ -0,0 +1,76 @@ +use gateway_core::operation::{GenerateRequest, ProtocolPayload}; +use provider_openai::encode_generate_request; +use serde_json::{Value, json}; + +#[test] +fn encoder_should_adapt_pi_responses_parameters_without_losing_codex_fields() { + // 对照本机 Pi 0.79.0 普通 Responses 适配在 onPayload 阶段生成的正文; + // maxTokens、temperature 和长缓存选项最终会产生下面三个顶层字段。 + let body = json!({ + "model": "client-model", + "input": [{"role": "user", "content": [{"type": "input_text", "text": "hello"}]}], + "stream": true, + "store": false, + "prompt_cache_key": "client-session", + "prompt_cache_retention": "24h", + "max_output_tokens": 512, + "temperature": 0.2, + "reasoning": {"effort": "high", "summary": "auto"}, + "include": ["reasoning.encrypted_content"] + }); + let payload = + ProtocolPayload::json_object("openai", body.as_object().expect("request object").clone()) + .expect("OpenAI payload"); + let encoded = encode_generate_request( + &GenerateRequest::from_protocol_payload(payload), + "gpt-test", + None, + ) + .expect("encode Pi request"); + + assert_eq!( + Value::Object(encoded.body().clone()), + json!({ + "model": "gpt-test", + "input": body["input"], + "stream": true, + "store": false, + "prompt_cache_key": "client-session", + "reasoning": {"effort": "high", "summary": "auto"}, + "include": ["reasoning.encrypted_content"] + }) + ); +} + +#[test] +fn encoder_should_preserve_unknown_fields_and_nested_business_parameters() { + let body = json!({ + "model": "client-model", + "input": "temperature and max_output_tokens are tool parameter names", + "max_tokens": 256, + "future_options": {"temperature": 0.7}, + "client_metadata": {"prompt_cache_retention": "business-value"}, + "tools": [{ + "type": "function", "name": "configure_sampler", + "parameters": { + "type": "object", + "properties": { + "temperature": {"type": "number"}, + "max_output_tokens": {"type": "integer"}, + "prompt_cache_retention": {"type": "string"} + } + } + }] + }); + let payload = + ProtocolPayload::json_object("openai", body.as_object().expect("request object").clone()) + .expect("OpenAI payload"); + let encoded = encode_generate_request( + &GenerateRequest::from_protocol_payload(payload), + "client-model", + None, + ) + .expect("encode business parameters"); + + assert_eq!(Value::Object(encoded.body().clone()), body); +} diff --git a/backend/crates/providers/openai/tests/transport/downstream/headers.rs b/backend/crates/providers/openai/tests/transport/downstream/headers.rs new file mode 100644 index 000000000..f963af845 --- /dev/null +++ b/backend/crates/providers/openai/tests/transport/downstream/headers.rs @@ -0,0 +1,442 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use gateway_core::operation::{GenerateRequest, ProtocolPayload}; +use provider_openai::encode_generate_request; +use tokio_tungstenite::tungstenite::http::HeaderMap as WsHeaderMap; + +use super::super::*; + +const DOWNSTREAM_TRANSPORT_HEADERS: &[(&str, &str)] = &[ + ("CF-Visitor", r#"{"scheme":"https"}"#), + ("cf-connecting-ip", "192.0.2.1"), + ("cf-connecting-ipv6", "2001:db8::1"), + ("cf-pseudo-ipv4", "240.0.0.1"), + ("cf-ray", "downstream-ray"), + ("cf-ipcountry", "US"), + ("cf-warp-tag-id", "downstream-warp"), + ("cf-worker", "worker.example"), + ("cf-ew-via", "15"), + ("cf-future-proxy-field", "future"), + ("x-forwarded-future-field", "future"), + ("x-real-ip", "192.0.2.1"), + ("true-client-ip", "192.0.2.1"), + ("x-request-id", "proxy-id"), + ("cdn-loop", "cloudflare; loops=1"), + ("via", "1.1 downstream-proxy"), + ("forwarded", "for=192.0.2.1;proto=https"), + ("x-forwarded-for", "192.0.2.1"), + ("x-forwarded-prefix", "/gateway"), + ("accept-encoding", "br, gzip"), + ("content-encoding", "downstream-encoding"), +]; + +const DOWNSTREAM_CLIENT_HEADERS: &[(&str, &str)] = &[ + ("X-Stainless-Runtime", "node"), + ("x-stainless-future-field", "sdk-detail"), + ("Origin", "https://client.invalid"), + ("Referer", "https://client.invalid/workspace"), + ("Sec-Ch-Ua", "synthetic-browser"), + ("sec-ch-ua-platform", "synthetic-platform"), + ("Sec-Fetch-Site", "same-origin"), + ("session_id", "synthetic-alias"), + ("sec-fetch-future", "future"), + ("sec-ch-ua-future", "future"), + ("x-grok-turn-idx", "7"), + ("x-xai-future-field", "future"), +]; + +fn request_with_opaque_headers(use_websocket: bool) -> CodexResponsesRequest { + let mut context = Map::from_iter([ + ( + "opaque_request_headers".to_owned(), + json!([ + ["x-openai-future-mode", STANDARD.encode(b"future-ascii")], + ["x-openai-future-mode", STANDARD.encode(b"\x80\xff")], + [ + "accept", + STANDARD.encode(b"application/vnd.openai.future+json") + ], + [ + "content-type", + STANDARD.encode(b"application/vnd.openai.request+json") + ], + ["user-agent", STANDARD.encode(b"Codex future-client")], + ["originator", STANDARD.encode(b"future-originator")], + ["version", STANDARD.encode(b"26.999.10001")], + ["version", STANDARD.encode(b"26.999.10002")], + ["openai-beta", STANDARD.encode(b"future_responses=v2")], + ["openai-beta", STANDARD.encode(b"future_tools=v3")], + [ + "x-openai-internal-codex-residency", + STANDARD.encode(b"future-region") + ], + ["x-codex-turn-state", STANDARD.encode(b"turn-ascii")], + ["x-codex-turn-state", STANDARD.encode(b"turn-\x80")], + ["bad header name", STANDARD.encode(b"ignored")], + ["x-invalid-base64", "%%%"], + ["x-still-valid", STANDARD.encode(b"after-invalid")], + ["traceparent", STANDARD.encode(b"synthetic-trace")], + ["x-business-origin", STANDARD.encode(b"keep")], + ["x-cf-business-field", STANDARD.encode(b"keep")], + ["sec-ch-business", STANDARD.encode(b"keep")], + ["x-stainlessbusiness", STANDARD.encode(b"keep")], + [ + "chatgpt-organization-id", + STANDARD.encode(b"unclassified-org") + ], + ["chatgpt-org-id", STANDARD.encode(b"unclassified-org")], + [ + "x-openai-organization", + STANDARD.encode(b"unclassified-org") + ], + ["x-openai-project", STANDARD.encode(b"unclassified-project")], + ["authorization", STANDARD.encode(b"Bearer client-secret")], + [ + "X-OpenAI-Actor-Authorization", + STANDARD.encode(b"proxy-managed") + ], + ["chatgpt-account-id", STANDARD.encode(b"client-account")], + [ + "x-codex-installation-id", + STANDARD.encode(b"client-installation") + ], + ["x-oai-attestation", STANDARD.encode(b"client-attestation")], + ["x-oai-is", STANDARD.encode(b"client-is")], + ["x-oai-is-update", STANDARD.encode(b"client-is-update")] + ]), + ), + ("turn_state".to_owned(), json!("typed-turn-state")), + ("session_id".to_owned(), json!("synthetic-alias")), + ]); + context + .get_mut("opaque_request_headers") + .and_then(Value::as_array_mut) + .expect("opaque headers") + .extend( + DOWNSTREAM_TRANSPORT_HEADERS + .iter() + .chain(DOWNSTREAM_CLIENT_HEADERS) + .map(|(name, value)| json!([name, STANDARD.encode(value.as_bytes())])), + ); + let payload = ProtocolPayload::json_object( + "openai", + json!({"model": "gpt-test", "input": "hello"}) + .as_object() + .expect("request object") + .clone(), + ) + .expect("OpenAI payload") + .with_context(context); + let mut request = encode_generate_request( + &GenerateRequest::from_protocol_payload(payload), + "gpt-routed", + None, + ) + .expect("opaque request headers"); + request.use_websocket = use_websocket; + request.force_http_sse = !use_websocket; + request +} + +async fn read_http_request_head(stream: &mut TcpStream) -> Vec { + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let read = stream.read(&mut buffer).await.expect("read HTTP request"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if let Some(end) = request.windows(4).position(|window| window == b"\r\n\r\n") { + request.truncate(end + 4); + break; + } + } + request +} + +fn raw_header_values(request: &[u8], target: &str) -> Vec> { + request + .split(|byte| *byte == b'\n') + .skip(1) + .take_while(|line| *line != b"\r" && !line.is_empty()) + .filter_map(|line| { + let line = line.strip_suffix(b"\r").unwrap_or(line); + let colon = line.iter().position(|byte| *byte == b':')?; + line[..colon] + .eq_ignore_ascii_case(target.as_bytes()) + .then(|| { + line[colon + 1..] + .iter() + .copied() + .skip_while(|byte| matches!(byte, b' ' | b'\t')) + .collect() + }) + }) + .collect() +} + +#[tokio::test] +async fn backend_http_should_preserve_business_headers_without_downstream_transport_headers() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind opaque HTTP server"); + let address = listener.local_addr().expect("opaque HTTP server address"); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept opaque HTTP client"); + let request = read_http_request_head(&mut stream).await; + write_completed_sse_response(&mut stream).await; + request + }); + // 不经 API 解码,直接构造协议上下文,验证 Provider 自身的过滤边界。 + let request = request_with_opaque_headers(false); + let profile = test_wire_profile(); + let profile_snapshot = profile.snapshot(); + let expected_user_agent = profile_snapshot.user_agent(); + let expected_core_version = profile_snapshot.codex_version; + let client = CodexBackendClient::new( + reqwest::Client::builder() + .no_proxy() + .build() + .expect("HTTP client"), + format!("http://{address}"), + profile, + ); + + client + .create_response( + &request, + CodexRequestContext { + trace: None, + authorization: "Bearer lease-token", + account_id: Some("lease-account"), + installation_id: Some("lease-installation"), + turn_state: request.turn_state.as_deref(), + version: Some("26.825.51511"), + session_id: request.client_session_id.as_deref(), + ..request_context("req_opaque_http", Some("lease-account")) + }, + ) + .await + .expect("opaque HTTP response"); + let raw = server.await.expect("opaque HTTP server task"); + + for &(name, _) in DOWNSTREAM_CLIENT_HEADERS { + assert!(raw_header_values(&raw, name).is_empty(), "leaked {name}"); + } + for &(name, _) in DOWNSTREAM_TRANSPORT_HEADERS { + // HTTP 正文由 transport 重编码为 zstd,不能沿用下游 Content-Encoding。 + if name == "content-encoding" { + assert_eq!(raw_header_values(&raw, name), vec![b"zstd".to_vec()]); + } else { + assert!( + raw_header_values(&raw, name).is_empty(), + "unexpected {name}" + ); + } + } + assert_eq!( + raw_header_values(&raw, "x-openai-future-mode"), + vec![b"future-ascii".to_vec(), b"\x80\xff".to_vec()] + ); + assert!(raw_header_values(&raw, "openai-beta").is_empty()); + assert_eq!( + raw_header_values(&raw, "x-codex-turn-state"), + vec![b"turn-ascii".to_vec(), b"turn-\x80".to_vec()] + ); + for (name, value) in [ + ("accept", b"text/event-stream".as_slice()), + ("content-type", b"application/json".as_slice()), + ("x-still-valid", b"after-invalid".as_slice()), + ("session-id", b"synthetic-alias".as_slice()), + ("authorization", b"Bearer lease-token".as_slice()), + ("chatgpt-account-id", b"lease-account".as_slice()), + ("chatgpt-organization-id", b"unclassified-org".as_slice()), + ("chatgpt-org-id", b"unclassified-org".as_slice()), + ("x-openai-organization", b"unclassified-org".as_slice()), + ("x-openai-project", b"unclassified-project".as_slice()), + ("x-codex-installation-id", b"client-installation".as_slice()), + ] { + assert_eq!(raw_header_values(&raw, name), vec![value.to_vec()]); + } + // 指纹头由运行时画像生成,不透传客户端值。 + assert_eq!( + raw_header_values(&raw, "user-agent"), + vec![expected_user_agent.as_bytes().to_vec()] + ); + assert_eq!( + raw_header_values(&raw, "originator"), + vec![b"codex_cli_rs".to_vec()] + ); + assert_eq!( + raw_header_values(&raw, "version"), + vec![expected_core_version.into_bytes()] + ); + for dropped in [ + "openai-beta", + "x-openai-actor-authorization", + "x-oai-attestation", + "x-oai-is", + "x-oai-is-update", + ] { + assert!( + raw_header_values(&raw, dropped).is_empty(), + "unexpected {dropped}" + ); + } + for name in [ + "traceparent", + "x-business-origin", + "x-cf-business-field", + "sec-ch-business", + "x-stainlessbusiness", + ] { + assert_eq!(raw_header_values(&raw, name).len(), 1, "missing {name}"); + } + for secret in [b"client-secret".as_slice(), b"client-account"] { + assert!(!raw.windows(secret.len()).any(|window| window == secret)); + } + for omitted in ["bad header name", "x-invalid-base64"] { + assert!(raw_header_values(&raw, omitted).is_empty()); + } +} + +#[tokio::test] +async fn backend_websocket_should_preserve_business_headers_without_downstream_transport_headers() { + let received = Arc::new(Mutex::new(WsHeaderMap::new())); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind opaque WebSocket server"); + let address = listener + .local_addr() + .expect("opaque WebSocket server address"); + let received_for_server = Arc::clone(&received); + let server = tokio::spawn(async move { + let (stream, _) = listener + .accept() + .await + .expect("accept opaque WebSocket client"); + let mut websocket = accept_codex_test_websocket_with(stream, move |request, response| { + response.headers_mut().insert( + "sec-websocket-extensions", + "permessage-deflate".parse().expect("extension header"), + ); + *received_for_server.lock().expect("opaque headers lock") = request.headers().clone(); + }) + .await; + let _ = websocket + .next() + .await + .expect("opaque response.create") + .expect("valid opaque response.create"); + websocket + .send(Message::Text( + completed_websocket_response("resp_opaque_headers", 1, 1).into(), + )) + .await + .expect("send opaque terminal event"); + }); + let request = request_with_opaque_headers(true); + let client = CodexBackendClient::new( + reqwest::Client::builder() + .no_proxy() + .build() + .expect("HTTP client"), + format!("http://{address}"), + test_wire_profile(), + ) + .with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)))); + + client + .create_response( + &request, + CodexRequestContext { + trace: None, + authorization: "Bearer lease-token", + account_id: Some("lease-account"), + installation_id: Some("lease-installation"), + turn_state: request.turn_state.as_deref(), + version: Some("26.825.51511"), + session_id: request.client_session_id.as_deref(), + ..request_context("req_opaque_ws", Some("lease-account")) + }, + ) + .await + .expect("opaque WebSocket response"); + server.await.expect("opaque WebSocket server task"); + let received = received.lock().expect("opaque headers lock"); + let values = |name: &str| { + received + .get_all(name) + .iter() + .map(|value| value.as_bytes().to_vec()) + .collect::>() + }; + + for (name, _) in DOWNSTREAM_TRANSPORT_HEADERS + .iter() + .chain(DOWNSTREAM_CLIENT_HEADERS) + { + assert!(values(name).is_empty(), "unexpected {name}"); + } + assert!( + received + .get("sec-websocket-extensions") + .expect("upstream WebSocket compression negotiation") + .to_str() + .expect("extension value") + .contains("permessage-deflate") + ); + assert_eq!( + values("x-openai-future-mode"), + vec![b"future-ascii".to_vec()] + ); + assert_eq!( + values("openai-beta"), + vec![b"responses_websockets=2026-02-06".to_vec()] + ); + assert!(values("accept").is_empty()); + assert!(values("content-type").is_empty()); + assert_eq!( + values("user-agent"), + vec![test_wire_profile().snapshot().user_agent().into_bytes()] + ); + assert_eq!(values("originator"), vec![b"codex_cli_rs".to_vec()]); + assert_eq!(values("version"), vec![b"1.2.3".to_vec()]); + assert!(values("x-openai-internal-codex-residency").is_empty()); + for dropped in [ + "x-openai-actor-authorization", + "x-oai-attestation", + "x-oai-is", + "x-oai-is-update", + ] { + assert!(values(dropped).is_empty(), "unexpected {dropped}"); + } + assert_eq!( + values("authorization"), + vec![b"Bearer lease-token".to_vec()] + ); + assert_eq!( + values("chatgpt-account-id"), + vec![b"lease-account".to_vec()] + ); + for name in [ + "traceparent", + "x-business-origin", + "x-cf-business-field", + "sec-ch-business", + "x-stainlessbusiness", + ] { + assert_eq!(values(name).len(), 1, "missing {name}"); + } + for (name, value) in [ + ("chatgpt-organization-id", b"unclassified-org".as_slice()), + ("chatgpt-org-id", b"unclassified-org".as_slice()), + ("x-openai-organization", b"unclassified-org".as_slice()), + ("x-openai-project", b"unclassified-project".as_slice()), + ("x-codex-installation-id", b"client-installation".as_slice()), + ] { + assert_eq!(values(name), vec![value.to_vec()], "missing {name}"); + } + assert_eq!(values("session-id"), vec![b"synthetic-alias".to_vec()]); + assert_eq!(values("x-codex-turn-state"), vec![b"turn-ascii".to_vec()]); +} diff --git a/backend/crates/providers/openai/tests/transport/downstream/mod.rs b/backend/crates/providers/openai/tests/transport/downstream/mod.rs new file mode 100644 index 000000000..8f7c24fba --- /dev/null +++ b/backend/crates/providers/openai/tests/transport/downstream/mod.rs @@ -0,0 +1,2 @@ +mod body; +mod headers; diff --git a/backend/crates/providers/openai/tests/transport/headers.rs b/backend/crates/providers/openai/tests/transport/headers.rs index 41e7dee6d..e9ed93fab 100644 --- a/backend/crates/providers/openai/tests/transport/headers.rs +++ b/backend/crates/providers/openai/tests/transport/headers.rs @@ -1,145 +1,8 @@ -use base64::{Engine as _, engine::general_purpose::STANDARD}; -use gateway_core::operation::{GenerateRequest, ProtocolPayload}; -use provider_openai::encode_generate_request; use provider_openai::transport::websocket::CodexWebSocketConnection; use serde_json::Value; -use tokio_tungstenite::tungstenite::http::HeaderMap as WsHeaderMap; use super::*; -const DOWNSTREAM_TRANSPORT_HEADERS: &[(&str, &str)] = &[ - ("CF-Visitor", r#"{"scheme":"https"}"#), - ("cf-connecting-ip", "192.0.2.1"), - ("cf-connecting-ipv6", "2001:db8::1"), - ("cf-pseudo-ipv4", "240.0.0.1"), - ("cf-ray", "downstream-ray"), - ("cf-ipcountry", "US"), - ("cf-warp-tag-id", "downstream-warp"), - ("cf-worker", "worker.example"), - ("cf-ew-via", "15"), - ("cdn-loop", "cloudflare; loops=1"), - ("via", "1.1 downstream-proxy"), - ("forwarded", "for=192.0.2.1;proto=https"), - ("x-forwarded-for", "192.0.2.1"), - ("x-forwarded-prefix", "/gateway"), - ("accept-encoding", "br, gzip"), - ("content-encoding", "downstream-encoding"), -]; - -fn request_with_opaque_headers(use_websocket: bool) -> CodexResponsesRequest { - let mut context = Map::from_iter([ - ( - "opaque_request_headers".to_owned(), - json!([ - ["x-openai-future-mode", STANDARD.encode(b"future-ascii")], - ["x-openai-future-mode", STANDARD.encode(b"\x80\xff")], - [ - "accept", - STANDARD.encode(b"application/vnd.openai.future+json") - ], - [ - "content-type", - STANDARD.encode(b"application/vnd.openai.request+json") - ], - ["user-agent", STANDARD.encode(b"Codex future-client")], - ["originator", STANDARD.encode(b"future-originator")], - ["version", STANDARD.encode(b"26.999.10001")], - ["version", STANDARD.encode(b"26.999.10002")], - ["openai-beta", STANDARD.encode(b"future_responses=v2")], - ["openai-beta", STANDARD.encode(b"future_tools=v3")], - [ - "x-openai-internal-codex-residency", - STANDARD.encode(b"future-region") - ], - ["x-codex-turn-state", STANDARD.encode(b"turn-ascii")], - ["x-codex-turn-state", STANDARD.encode(b"turn-\x80")], - ["bad header name", STANDARD.encode(b"ignored")], - ["x-invalid-base64", "%%%"], - ["x-still-valid", STANDARD.encode(b"after-invalid")], - ["authorization", STANDARD.encode(b"Bearer client-secret")], - [ - "X-OpenAI-Actor-Authorization", - STANDARD.encode(b"proxy-managed") - ], - ["chatgpt-account-id", STANDARD.encode(b"client-account")], - [ - "x-codex-installation-id", - STANDARD.encode(b"client-installation") - ], - ["x-oai-attestation", STANDARD.encode(b"client-attestation")], - ["x-oai-is", STANDARD.encode(b"client-is")], - ["x-oai-is-update", STANDARD.encode(b"client-is-update")] - ]), - ), - ("turn_state".to_owned(), json!("typed-turn-state")), - ]); - context - .get_mut("opaque_request_headers") - .and_then(Value::as_array_mut) - .expect("opaque headers") - .extend( - DOWNSTREAM_TRANSPORT_HEADERS - .iter() - .map(|(name, value)| json!([name, STANDARD.encode(value.as_bytes())])), - ); - let payload = ProtocolPayload::json_object( - "openai", - json!({"model": "gpt-test", "input": "hello"}) - .as_object() - .expect("request object") - .clone(), - ) - .expect("OpenAI payload") - .with_context(context); - let mut request = encode_generate_request( - &GenerateRequest::from_protocol_payload(payload), - "gpt-routed", - None, - ) - .expect("opaque request headers"); - request.use_websocket = use_websocket; - request.force_http_sse = !use_websocket; - request -} - -async fn read_http_request_head(stream: &mut TcpStream) -> Vec { - let mut request = Vec::new(); - let mut buffer = [0_u8; 1024]; - loop { - let read = stream.read(&mut buffer).await.expect("read HTTP request"); - if read == 0 { - break; - } - request.extend_from_slice(&buffer[..read]); - if let Some(end) = request.windows(4).position(|window| window == b"\r\n\r\n") { - request.truncate(end + 4); - break; - } - } - request -} - -fn raw_header_values(request: &[u8], target: &str) -> Vec> { - request - .split(|byte| *byte == b'\n') - .skip(1) - .take_while(|line| *line != b"\r" && !line.is_empty()) - .filter_map(|line| { - let line = line.strip_suffix(b"\r").unwrap_or(line); - let colon = line.iter().position(|byte| *byte == b':')?; - line[..colon] - .eq_ignore_ascii_case(target.as_bytes()) - .then(|| { - line[colon + 1..] - .iter() - .copied() - .skip_while(|byte| matches!(byte, b' ' | b'\t')) - .collect() - }) - }) - .collect() -} - #[test] fn websocket_connection_should_preserve_endpoint_and_header_order() { let connection = CodexWebSocketConnection::new( @@ -381,234 +244,6 @@ async fn backend_websocket_should_forward_context_headers_and_preserve_payload_f } } -#[tokio::test] -async fn backend_http_should_preserve_business_headers_without_downstream_transport_headers() { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("bind opaque HTTP server"); - let address = listener.local_addr().expect("opaque HTTP server address"); - let server = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept opaque HTTP client"); - let request = read_http_request_head(&mut stream).await; - write_completed_sse_response(&mut stream).await; - request - }); - let request = request_with_opaque_headers(false); - let profile = test_wire_profile(); - let profile_snapshot = profile.snapshot(); - let expected_user_agent = profile_snapshot.user_agent(); - let expected_core_version = profile_snapshot.codex_version; - let client = CodexBackendClient::new( - reqwest::Client::builder() - .no_proxy() - .build() - .expect("HTTP client"), - format!("http://{address}"), - profile, - ); - - client - .create_response( - &request, - CodexRequestContext { - trace: None, - authorization: "Bearer lease-token", - account_id: Some("lease-account"), - installation_id: Some("lease-installation"), - turn_state: request.turn_state.as_deref(), - version: Some("26.825.51511"), - ..request_context("req_opaque_http", Some("lease-account")) - }, - ) - .await - .expect("opaque HTTP response"); - let raw = server.await.expect("opaque HTTP server task"); - - for &(name, _) in DOWNSTREAM_TRANSPORT_HEADERS { - // HTTP 正文由 transport 重编码为 zstd,不能沿用下游 Content-Encoding。 - if name == "content-encoding" { - assert_eq!(raw_header_values(&raw, name), vec![b"zstd".to_vec()]); - } else { - assert!( - raw_header_values(&raw, name).is_empty(), - "unexpected {name}" - ); - } - } - assert_eq!( - raw_header_values(&raw, "x-openai-future-mode"), - vec![b"future-ascii".to_vec(), b"\x80\xff".to_vec()] - ); - assert!(raw_header_values(&raw, "openai-beta").is_empty()); - assert_eq!( - raw_header_values(&raw, "x-codex-turn-state"), - vec![b"turn-ascii".to_vec(), b"turn-\x80".to_vec()] - ); - for (name, value) in [ - ("accept", b"text/event-stream".as_slice()), - ("content-type", b"application/json".as_slice()), - ("x-still-valid", b"after-invalid".as_slice()), - ("authorization", b"Bearer lease-token".as_slice()), - ("chatgpt-account-id", b"lease-account".as_slice()), - ] { - assert_eq!(raw_header_values(&raw, name), vec![value.to_vec()]); - } - // 指纹头由运行时画像生成,不透传客户端值。 - assert_eq!( - raw_header_values(&raw, "user-agent"), - vec![expected_user_agent.as_bytes().to_vec()] - ); - assert_eq!( - raw_header_values(&raw, "originator"), - vec![b"codex_cli_rs".to_vec()] - ); - assert_eq!( - raw_header_values(&raw, "version"), - vec![expected_core_version.into_bytes()] - ); - for dropped in [ - "openai-beta", - "x-openai-actor-authorization", - "x-oai-attestation", - "x-oai-is", - "x-oai-is-update", - ] { - assert!( - raw_header_values(&raw, dropped).is_empty(), - "unexpected {dropped}" - ); - } - for secret in [ - b"client-secret".as_slice(), - b"client-account", - b"client-installation", - ] { - assert!(!raw.windows(secret.len()).any(|window| window == secret)); - } - for omitted in ["bad header name", "x-invalid-base64"] { - assert!(raw_header_values(&raw, omitted).is_empty()); - } -} - -#[tokio::test] -async fn backend_websocket_should_preserve_business_headers_without_downstream_transport_headers() { - let received = Arc::new(Mutex::new(WsHeaderMap::new())); - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("bind opaque WebSocket server"); - let address = listener - .local_addr() - .expect("opaque WebSocket server address"); - let received_for_server = Arc::clone(&received); - let server = tokio::spawn(async move { - let (stream, _) = listener - .accept() - .await - .expect("accept opaque WebSocket client"); - let mut websocket = accept_codex_test_websocket_with(stream, move |request, response| { - response.headers_mut().insert( - "sec-websocket-extensions", - "permessage-deflate".parse().expect("extension header"), - ); - *received_for_server.lock().expect("opaque headers lock") = request.headers().clone(); - }) - .await; - let _ = websocket - .next() - .await - .expect("opaque response.create") - .expect("valid opaque response.create"); - websocket - .send(Message::Text( - completed_websocket_response("resp_opaque_headers", 1, 1).into(), - )) - .await - .expect("send opaque terminal event"); - }); - let request = request_with_opaque_headers(true); - let client = CodexBackendClient::new( - reqwest::Client::builder() - .no_proxy() - .build() - .expect("HTTP client"), - format!("http://{address}"), - test_wire_profile(), - ) - .with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)))); - - client - .create_response( - &request, - CodexRequestContext { - trace: None, - authorization: "Bearer lease-token", - account_id: Some("lease-account"), - installation_id: Some("lease-installation"), - turn_state: request.turn_state.as_deref(), - version: Some("26.825.51511"), - ..request_context("req_opaque_ws", Some("lease-account")) - }, - ) - .await - .expect("opaque WebSocket response"); - server.await.expect("opaque WebSocket server task"); - let received = received.lock().expect("opaque headers lock"); - let values = |name: &str| { - received - .get_all(name) - .iter() - .map(|value| value.as_bytes().to_vec()) - .collect::>() - }; - - for (name, _) in DOWNSTREAM_TRANSPORT_HEADERS { - assert!(values(name).is_empty(), "unexpected {name}"); - } - assert!( - received - .get("sec-websocket-extensions") - .expect("upstream WebSocket compression negotiation") - .to_str() - .expect("extension value") - .contains("permessage-deflate") - ); - assert_eq!( - values("x-openai-future-mode"), - vec![b"future-ascii".to_vec()] - ); - assert_eq!( - values("openai-beta"), - vec![b"responses_websockets=2026-02-06".to_vec()] - ); - assert!(values("accept").is_empty()); - assert!(values("content-type").is_empty()); - assert_eq!( - values("user-agent"), - vec![test_wire_profile().snapshot().user_agent().into_bytes()] - ); - assert_eq!(values("originator"), vec![b"codex_cli_rs".to_vec()]); - assert_eq!(values("version"), vec![b"1.2.3".to_vec()]); - assert!(values("x-openai-internal-codex-residency").is_empty()); - for dropped in [ - "x-openai-actor-authorization", - "x-oai-attestation", - "x-oai-is", - "x-oai-is-update", - ] { - assert!(values(dropped).is_empty(), "unexpected {dropped}"); - } - assert_eq!( - values("authorization"), - vec![b"Bearer lease-token".to_vec()] - ); - assert_eq!( - values("chatgpt-account-id"), - vec![b"lease-account".to_vec()] - ); - assert!(values("x-codex-installation-id").is_empty()); - assert_eq!(values("x-codex-turn-state"), vec![b"turn-ascii".to_vec()]); -} - #[tokio::test] async fn backend_http_should_send_codex_context_without_browser_headers() { let listener = TcpListener::bind("127.0.0.1:0") diff --git a/backend/crates/providers/openai/tests/transport/mod.rs b/backend/crates/providers/openai/tests/transport/mod.rs index 34638fd4a..b0c70ec8e 100644 --- a/backend/crates/providers/openai/tests/transport/mod.rs +++ b/backend/crates/providers/openai/tests/transport/mod.rs @@ -50,6 +50,7 @@ mod canonical; mod catalog; mod client; mod diagnostics; +mod downstream; mod endpoints; mod headers; mod http_client; diff --git a/backend/crates/providers/openai/tests/transport/request.rs b/backend/crates/providers/openai/tests/transport/request.rs index 1525c7a31..2934a6e77 100644 --- a/backend/crates/providers/openai/tests/transport/request.rs +++ b/backend/crates/providers/openai/tests/transport/request.rs @@ -179,28 +179,6 @@ fn encoder_should_patch_model_and_preserve_supported_generate_semantics() { assert!(!encoded.force_http_sse); } -#[test] -fn encoder_should_remove_unsupported_fields_from_upstream_body() { - let request = request(Map::from_iter([ - ("model".to_owned(), json!("client-model")), - ("input".to_owned(), json!("hello")), - ("max_output_tokens".to_owned(), json!(512)), - ("max_tokens".to_owned(), json!(256)), - ("temperature".to_owned(), json!(0.2)), - ])); - - let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode"); - - assert_eq!( - Value::Object(encoded.body().clone()), - json!({ - "model": "gpt-test", - "input": "hello", - "max_tokens": 256, - }) - ); -} - #[test] fn encoder_should_align_structured_location_fields_without_rewriting_chat_text() { let normal_chat = "\n 2026-09-13\n \ diff --git a/docs/api.md b/docs/api.md index df4699cdc..b885c942d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -169,6 +169,24 @@ Responses 不透传下游的逐跳头、反代元数据(如 `cf-*`、`x-forwar API Key 上游还会移除 Cookie、ChatGPT 账号身份、`x-codex-*`、`x-openai-internal-*`、会话/线程身份头及 `X-OpenAI-Actor-Authorization`,避免把 OAuth 或网关托管身份传给第三方 API。 +Responses 也不透传 `x-stainless-*`、`Origin`、`Referer`、`sec-ch-ua*` 和 `sec-fetch-*` +携带的下游 SDK/浏览器环境或页面来源。兼容基准是 Codex Core/Desktop 请求协议; +OpenAI 官方 SDK 也会发送 `x-stainless-*`,浏览器字段也有标准定义,过滤不表示这些头非法。 +规则不依赖下游 User-Agent,也不改变原始入站请求供 CORS、鉴权和本地观测使用的字段。 +`session_id` 请求头仅作为入站会话别名,提取后不再原样透传,上游通过 `session-id` 表达; +两者同时存在时仍优先使用 `session-id`。正文中的 `client_metadata.session_id`、 +`prompt_cache_key` 不受这条请求头规则影响。 +`thread-id`、turn metadata 等 Codex 协议字段及未知业务扩展继续按既有合同处理; +`traceparent`、`tracestate` 不因属于追踪字段而被删除。 + +Responses 上游编码会移除 Codex 不接受的顶层 `temperature`、`max_output_tokens` 和 +`prompt_cache_retention`;这些参数可能来自 Pi 等客户端的普通 OpenAI Responses 适配。 +`prompt_cache_key`、`reasoning`、`include` 等 Codex 参数继续保留。过滤只作用于顶层, +不删除工具参数 schema、输入内容或 `client_metadata` 内的同名业务字段;其他未知字段继续透传。 + +这不是客户端匿名化:系统提示词、工具定义、工具结果、工作目录及其他业务 metadata 仍可能 +透露客户端环境,网关不对正文做客户端品牌清洗。 + Responses WebSocket 仅接受文本 `response.create`,同一连接串行执行。当前响应期间收到的后续业务帧 留在有界接收队列中,待当前响应完成终结和写出后再逐条校验、准入与执行,不因请求提前到达而断开。 接收队列容量为 32 个事件,超载仍关闭连接;Ping/Pong、客户端关闭和服务关闭不等待队列中的请求执行。 diff --git a/docs/architecture.md b/docs/architecture.md index 0e17c1bae..455823a67 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -195,9 +195,17 @@ API Key 默认 HTTP/SSE,可选 WS 优先;选号先验证传输资格,WS po - OpenAI 是透明边界。Responses 请求保留未知字段和字段顺序;SSE、WebSocket、Images 与 standalone Search 的业务正文按原始字节转发。canonical facts 从同一数据旁路提取,只用于路由、观测和计费。 -- Responses 的业务扩展头保留原始多值字节;传输与反代请求头分类由 `gateway-protocol` 统一定义, - API 入站与 OpenAI Provider 编码共同使用。下游链路元数据和压缩协商不跨越该边界, - 上游认证、请求画像与传输字段仍由 Provider 生成;响应方向的诊断头不受请求过滤规则影响。 +- Responses 的业务扩展头保留原始多值字节。API 负责剥离鉴权、账号身份和 HTTP 传输字段, + 并提取会话语义;`gateway-protocol` 共享 HTTP 传输与网关链路字段分类。客户端兼容规则集中在 + `providers/openai/src/transport/downstream/`:`headers.rs` 管理下游环境头和已提取语义的头部别名, + `body.rs` 管理不适用于 Codex Responses 的已知顶层参数;兼容基准为 Codex Core/Desktop 请求协议, + 不持有账号身份保护或会话规范化逻辑。OpenAI 官方 SDK 的 + `x-stainless-*` 也描述下游 SDK 环境;字段合法性与是否跨链路透传是不同判断。 + Provider 在 `transport/request.rs` 解码不透明头时组合兼容、身份与 HTTP 规则, + 同时调用正文兼容规则;HTTP/SSE 与 WebSocket 共用此边界。 + `transport/headers.rs` 负责上游身份保护和官方头组装。 + 会话别名只规范化请求头,不清除正文身份字段;未知业务扩展与响应诊断头不受影响,字段见 + [Responses 合同](api.md#3-openai-数据面与模型目录)。提示词、工具及业务正文不做客户端品牌清洗。 - xAI 是翻译边界。Provider 把 Grok wire 转换为 Responses wire;上游结构化错误的 message/code/type 可以透出,但账号指纹会先脱敏。 - response ID 是不透明 UTF-8 bytes,不假设 UUID、固定长度或跨 Provider 可复用。