From 1e6d69a3499ea67762289042aa84e22745f2cb27 Mon Sep 17 00:00:00 2001 From: Clarence12138 <148194843+Clarence12138@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:30:52 +0800 Subject: [PATCH] fix(openai): align downstream requests with Codex protocol (#117) Centralize downstream SDK, browser and proxy header compatibility in the OpenAI provider. Normalize the session header alias and isolate known non-Codex body parameters while preserving ordinary extensions. Align account installation metadata with Codex Core and remove unverified identity header classifications. Cover HTTP and WebSocket behavior and document the request boundaries. Validation: 1,105 scoped and architecture tests, Rustfmt and strict scoped Clippy passed. Four real-account HTTP/WebSocket cases passed on gpt-5.5, including connection reuse. Earlier gpt-reserve overload failures were recorded separately without changing production retry policy. Co-authored-by: Clarence <1297529513@qq.com> Co-authored-by: Zyy Co-authored-by: Codex --- .../src/openai/responses/request.rs | 8 - .../tests/openai/responses/http.rs | 39 +- .../gateway-api/tests/openai/responses/mod.rs | 139 +++++- .../openai/responses/websocket/protocol.rs | 14 +- .../gateway-protocol/src/openai/headers.rs | 13 +- .../gateway-protocol/tests/openai/headers.rs | 50 +- .../openai/src/transport/downstream/body.rs | 21 + .../src/transport/downstream/headers.rs | 42 ++ .../openai/src/transport/downstream/mod.rs | 8 + .../providers/openai/src/transport/headers.rs | 18 +- .../providers/openai/src/transport/mod.rs | 1 + .../providers/openai/src/transport/request.rs | 45 +- .../openai/tests/provider/contract.rs | 91 +++- .../openai/tests/transport/downstream/body.rs | 76 +++ .../tests/transport/downstream/headers.rs | 442 ++++++++++++++++++ .../openai/tests/transport/downstream/mod.rs | 2 + .../openai/tests/transport/headers.rs | 365 --------------- .../providers/openai/tests/transport/mod.rs | 1 + .../openai/tests/transport/request.rs | 22 - docs/api.md | 18 + docs/architecture.md | 14 +- 21 files changed, 941 insertions(+), 488 deletions(-) create mode 100644 backend/crates/providers/openai/src/transport/downstream/body.rs create mode 100644 backend/crates/providers/openai/src/transport/downstream/headers.rs create mode 100644 backend/crates/providers/openai/src/transport/downstream/mod.rs create mode 100644 backend/crates/providers/openai/tests/transport/downstream/body.rs create mode 100644 backend/crates/providers/openai/tests/transport/downstream/headers.rs create mode 100644 backend/crates/providers/openai/tests/transport/downstream/mod.rs 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 可复用。