From 56d8476275fb0e92554cb7faadc3bb2099b3637c Mon Sep 17 00:00:00 2001 From: zyycn Date: Mon, 15 Jun 2026 11:34:54 +0800 Subject: [PATCH] codex: forward body version Co-authored-by: Codex --- docs/OPENAI_CHAIN_PARITY_REAUDIT.md | 7 +++-- src/codex/serving/responses.rs | 5 ++- tests/codex_serving/responses_http_sse.rs | 38 ++++++++++++++++------- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/docs/OPENAI_CHAIN_PARITY_REAUDIT.md b/docs/OPENAI_CHAIN_PARITY_REAUDIT.md index eb17960a7..8c474ecd2 100644 --- a/docs/OPENAI_CHAIN_PARITY_REAUDIT.md +++ b/docs/OPENAI_CHAIN_PARITY_REAUDIT.md @@ -249,7 +249,8 @@ Rust 证据: - 本轮修正后,`src/codex/serving/dispatch/mod.rs` 的 `stagger_request_with_deps()` 会在同账号 previous slot 存在时按 `request_interval_ms` 的 ±30% jitter 计算等待时间;`tests/codex_serving/responses_http_sse.rs` 覆盖同账号并发请求发送上游前会等待,`jitter_request_interval_ms_with_factor_should_match_original_bounds` 覆盖 0.7/1.3 边界。 - 本轮修正后,`src/codex/gateway/transport/http_client.rs` 的 Rust reqwest client 按 `force_http11` 缓存复用,使用 `reqwest = 0.12.28`、`rustls = 0.23.36`,启用 rustls/native roots、`pool_max_idle_per_host(4)`、`tcp_keepalive(30s)`、gzip/brotli/zstd/deflate,并可选 `http1_only()`;同时显式 `.no_proxy()`。 - `tests/codex_gateway/http_client.rs:13-78` 覆盖 HTTP SSE POST、desktop headers、Cookie 注入、`Set-Cookie` 捕获、turn state 捕获和 usage 提取。 -- `tests/codex_serving/responses_http_sse.rs:403-513` 覆盖 `/v1/responses` HTTP SSE 路径的 context headers、account-scoped prompt cache key、metadata 与 service tier。 +- 本轮修正后,`src/codex/serving/responses.rs` 会按原版从 body 读取 `version`,并以 body 优先、header 兜底的顺序写入上游 `version` header;本地字段仍不会进入上游 JSON body。 +- `tests/codex_serving/responses_http_sse.rs::v1_responses_should_forward_parity_fields_and_context_headers` 覆盖 `/v1/responses` HTTP SSE 路径的 context headers、body control 字段、account-scoped prompt cache key、metadata、subagent、service tier,以及 `turnState`/`turnMetadata`/`betaFeatures`/`includeTimingMetrics`/`version`/`codexWindowId`/`parentThreadId`/`use_websocket` 不泄漏到上游 body。 结论:部分对齐。 @@ -262,6 +263,7 @@ Rust 证据: - Rust 与原版 native transport 使用同一 reqwest/rustls 主版本组合,且都支持 `force_http11`。 - `/v1/responses/compact` 已对齐原版 JSON 上游链路:URL/method、核心 headers、Cookie/Set-Cookie、非 SSE Accept、body 字段白名单、reasoning/text 清洗和 JSON 响应透传均有实现;`v1_responses_compact_should_post_json_to_codex_compact_upstream` 覆盖该路径。 - HTTP SSE 与 compact 请求的真实 wire header 相对顺序已由 raw TCP 测试覆盖,不再只是字段级断言。 +- HTTP SSE golden test 已覆盖 body control 字段转 header/metadata、账号作用域 identity,以及本地控制字段不进入上游 JSON body。 - IP 代理/VPN/proxy config/HttpsProxyAgent 是用户明确排除的非目标。Rust `.no_proxy()` 与原版 proxy 支持的差异不计入本项目 100% 一致性缺口,但需要保留边界说明。 未完全对齐: @@ -269,8 +271,7 @@ Rust 证据: - 原版 native client builder 没有显式 `.no_proxy()`,而是通过 proxy 参数控制;Rust 显式 `.no_proxy()` 符合非代理目标,但也意味着环境代理不会生效。此项按非目标处理,不算 OpenAI 链路缺口。 缺口/后续动作: -- 增加 HTTP SSE golden test:断言上游 body 不包含本地控制字段,header/body 中 account-scoped identity、metadata、subagent、turn/context 字段与原版一致。 -- 若要宣称安全模拟 100%,还需要处理 WS handshake header order、header casing、TLS 指纹和客户端断开 abort 语义;当前 HTTP SSE/compact order 已有 wire-level 证据。 +- 若要宣称安全模拟 100%,还需要处理 TLS 指纹和客户端断开 abort 语义;当前 HTTP SSE/compact order 与 HTTP SSE body golden 已有 wire-level/端到端证据。 ## 6. WebSocket 上游链路与连接池 diff --git a/src/codex/serving/responses.rs b/src/codex/serving/responses.rs index f55d701b7..e24217243 100644 --- a/src/codex/serving/responses.rs +++ b/src/codex/serving/responses.rs @@ -133,7 +133,7 @@ impl ResponsesService { codex_request.include_timing_metrics = body .include_timing_metrics .or_else(|| header_string(&headers, "x-responsesapi-include-timing-metrics")); - codex_request.version = header_string(&headers, "version"); + codex_request.version = body.version.or_else(|| header_string(&headers, "version")); codex_request.codex_window_id = body .codex_window_id .or_else(|| header_string(&headers, "x-codex-window-id")); @@ -934,6 +934,7 @@ struct ResponsesBody { turn_state: Option, turn_metadata: Option, beta_features: Option, + version: Option, include_timing_metrics: Option, codex_window_id: Option, parent_thread_id: Option, @@ -975,6 +976,7 @@ fn parse_responses_body(body: &[u8], default_model: String) -> Option ResponsesBody { turn_state: None, turn_metadata: None, beta_features: None, + version: None, include_timing_metrics: None, codex_window_id: None, parent_thread_id: None, diff --git a/tests/codex_serving/responses_http_sse.rs b/tests/codex_serving/responses_http_sse.rs index d198753e3..69f5d2d56 100644 --- a/tests/codex_serving/responses_http_sse.rs +++ b/tests/codex_serving/responses_http_sse.rs @@ -625,11 +625,11 @@ async fn v1_responses_should_forward_parity_fields_and_context_headers() { .and(header("authorization", "Bearer access-secret")) .and(header("chatgpt-account-id", "chatgpt-account")) .and(header("x-codex-turn-state", "turn-body")) - .and(header("x-codex-turn-metadata", "meta-header")) - .and(header("x-codex-beta-features", "beta-header")) + .and(header("x-codex-turn-metadata", "meta-body")) + .and(header("x-codex-beta-features", "beta-body")) .and(header("x-responsesapi-include-timing-metrics", "true")) .and(header("version", "2026-06-12")) - .and(header("x-codex-parent-thread-id", "parent-1")) + .and(header("x-codex-parent-thread-id", "parent-body")) .and(header("x-openai-subagent", "review")) .respond_with( ResponseTemplate::new(200) @@ -654,10 +654,8 @@ async fn v1_responses_should_forward_parity_fields_and_context_headers() { .header("x-codex-turn-state", "turn-header") .header("x-codex-turn-metadata", "meta-header") .header("x-codex-beta-features", "beta-header") - .header("x-responsesapi-include-timing-metrics", "true") - .header("version", "2026-06-12") - .header("x-codex-window-id", "window-1") - .header("x-codex-parent-thread-id", "parent-1") + .header("x-codex-window-id", "window-header") + .header("x-codex-parent-thread-id", "parent-header") .header("x-openai-subagent", "review") .body(Body::from( json!({ @@ -685,6 +683,12 @@ async fn v1_responses_should_forward_parity_fields_and_context_headers() { "safe": "yes" }, "turnState": "turn-body", + "turnMetadata": "meta-body", + "betaFeatures": "beta-body", + "includeTimingMetrics": "true", + "version": "2026-06-12", + "codexWindowId": "window-body", + "parentThreadId": "parent-body", "use_websocket": false }) .to_string(), @@ -700,7 +704,8 @@ async fn v1_responses_should_forward_parity_fields_and_context_headers() { let requests = server.received_requests().await.unwrap(); assert_eq!(requests.len(), 1); let upstream_body: Value = serde_json::from_slice(&requests[0].body).unwrap(); - let identity = build_conversation_identity(Some("pcache"), Some("window-1"), "acct_imported"); + let identity = + build_conversation_identity(Some("pcache"), Some("window-body"), "acct_imported"); assert_eq!( upstream_body["prompt_cache_key"], identity.conversation_id.unwrap() @@ -718,17 +723,28 @@ async fn v1_responses_should_forward_parity_fields_and_context_headers() { ); assert_eq!( upstream_body["client_metadata"]["x-codex-turn-metadata"], - "meta-header" + "meta-body" ); assert_eq!( upstream_body["client_metadata"]["x-codex-parent-thread-id"], - "parent-1" + "parent-body" ); let installation_id = upstream_body["client_metadata"]["x-codex-installation-id"] .as_str() .unwrap(); assert!(uuid::Uuid::parse_str(installation_id).is_ok()); - assert!(upstream_body.get("use_websocket").is_none()); + for local_field in [ + "turnState", + "turnMetadata", + "betaFeatures", + "includeTimingMetrics", + "version", + "codexWindowId", + "parentThreadId", + "use_websocket", + ] { + assert!(upstream_body.get(local_field).is_none()); + } } #[tokio::test]