diff --git a/docs/official-codex-request-chain.md b/docs/official-codex-request-chain.md index 0864b981c..406cb826c 100644 --- a/docs/official-codex-request-chain.md +++ b/docs/official-codex-request-chain.md @@ -16,7 +16,7 @@ - 对话主通道同时存在 `responses_http` 和 `responses_websocket` 两类 transport。 - WebSocket beta 头为 `OpenAI-Beta: responses_websockets=2026-02-06`。 -- 请求头会携带 `Authorization`、`ChatGPT-Account-Id`、`originator`、`User-Agent`。 +- WebSocket opening 请求头会携带 `Authorization`、`ChatGPT-Account-Id`、`originator`、`User-Agent`、`x-client-request-id`、`session-id`、`thread-id`、`x-codex-window-id`、`x-codex-turn-metadata`。 - 原生二进制包含 `x-client-request-id`、`x-oai-attestation`、`x-responsesapi-include-timing-metrics`、`x-codex-installation-id`、`x-codex-turn-state` 等请求相关字段。 - 请求体/WS payload 关键字段包含 `model`、`instructions`、`input`、`parallel_tool_calls`、`previous_response_id`、`store`、`include`、`client_metadata`。 - 官方响应处理以 `response.completed` 作为完成边界;二进制里有 `stream closed before response.completed` 和 `remote compaction v2 stream closed before response.completed`。 @@ -99,6 +99,21 @@ - 官方错误文案包含 `Responses websocket connection limit reached (60 minutes). Create a new websocket connection to continue.`,说明 WebSocket 连接有 60 分钟生命周期或服务端连接限制。 - 官方安全拦截文案包含 `This request has been flagged for possible cybersecurity risk.`,属于请求内容/工具调用侧风控信号,不是普通额度不足。 +### 运行态 WebSocket 抓包 + +文件:本地取证 `docs/flows.all.txt` 和已提交的 `docs/openai-res.txt`。`flows.all.txt` 是完整抓包,内容较大且包含大量请求正文,不作为普通文档资产提交。 + +确认点: + +- Flow #41/#44/#48/#59/#60 均为 `GET /backend-api/codex/responses` WebSocket opening。 +- opening 基础头包含 `Host`、`Connection: Upgrade`、`Upgrade: websocket`、`Sec-WebSocket-Version: 13`、`Sec-WebSocket-Key`。 +- opening 业务头包含 `chatgpt-account-id`、`authorization`、`user-agent: Codex Desktop/...`、`originator: Codex Desktop`、`openai-beta: responses_websockets=2026-02-06`。 +- opening 会话头包含 `x-client-request-id`、`session-id`、`thread-id`、`x-codex-window-id`、`x-codex-turn-metadata`,随后是 `sec-websocket-extensions: permessage-deflate; client_max_window_bits`。 +- payload 的 `client_metadata` 可见 `session_id`、`thread_id`、`x-codex-installation-id`、`x-codex-window-id`、`x-codex-turn-metadata` 和 `x-codex-ws-stream-request-start-ms`。 +- 服务端会下发内部 `codex.rate_limits` 事件;本次 free 样本为 `plan_type = "free"`、`primary.window_minutes = 43200`、`secondary = null`、`additional_rate_limits = null`、`credits = null`。 + +证据边界:本抓包只覆盖 WebSocket opening 和 WebSocket 事件;HTTP SSE 是否完全同头只能结合官方二进制的 shared request header 线索和当前项目的 shared header builder 推断。 + ### Webview 额度逻辑 相关文件: @@ -216,7 +231,7 @@ | 请求类型 | 路径 | 传输 | 当前头部规则 | | --- | --- | --- | --- | -| Responses HTTP SSE | `/codex/responses` | `reqwest` POST | 指纹基础头 + `content-type: application/json` + 可选 `cookie` + `accept: text/event-stream` + `openai-beta: responses_websockets=2026-02-06` + `x-openai-internal-codex-residency: us` + `x-client-request-id` + Codex 上下文头 | +| Responses HTTP SSE | `/codex/responses` | `reqwest` POST | 指纹基础头 + `content-type: application/json` + 可选 `cookie` + `accept: text/event-stream` + `openai-beta: responses_websockets=2026-02-06` + `x-openai-internal-codex-residency: us` + `x-client-request-id` + `session-id` + `thread-id` + Codex 上下文头 | | Responses WebSocket | `/codex/responses` | `tokio-tungstenite` GET upgrade + 首帧 `response.create` | 先按 Responses HTTP 头构造,再移除 `content-type` 和 `accept`,追加标准 WebSocket upgrade 头和 `sec-websocket-extensions` | | Compact | `/codex/responses/compact` | `reqwest` POST | 指纹基础头 + `content-type` + 可选 `cookie` + `openai-beta` + `x-openai-internal-codex-residency` + 新生成的 `x-client-request-id` + 可选 `x-codex-installation-id` | | Models/探测 | `/codex/models`、`/models`、`/sentinel/chat-requirements` | `reqwest` GET | 指纹基础头 + 可选 `cookie` + `accept: application/json` + `accept-encoding: gzip, deflate` + 可选 `x-codex-installation-id` | @@ -231,19 +246,22 @@ - `sec-ch-ua` - fingerprint 默认 header 和 header order,例如 `accept-encoding`、`accept-language`、`sec-ch-ua-mobile`、`sec-ch-ua-platform`、`sec-fetch-*` -Responses/WS/Compact 的 Codex 上下文头: +Responses/WS 的 Codex 上下文头: -- `x-client-request-id`:主 Responses 请求优先使用 `session_id`,没有 session 时使用本次 `request_id`;Compact 每次新生成 UUID。 +- `x-client-request-id`:主 Responses 请求优先使用 conversation identity,没有 session 时使用本次 `request_id`;Compact 每次新生成 UUID。 - `x-codex-installation-id`:有 installation id 时写入。 -- `session_id`:仅主 Responses 请求有 conversation identity 时写入。 +- `session-id` / `thread-id`:仅主 Responses 请求有 conversation identity 时写入;当前运行态 WS 抓包里二者同值。 - `x-codex-window-id`:由请求 `codex_window_id` 或会话身份推导。 - `x-codex-turn-state`:显式续链或历史恢复时携带。 - `x-codex-turn-metadata`、`x-codex-beta-features`、`x-responsesapi-include-timing-metrics`、`version`、`x-codex-parent-thread-id`:客户端 body/header 透传后写入。 - `x-openai-subagent`:只接受 `review`、`compact`、`memory_consolidation`、`collab_spawn`。 +Compact 只保留 `x-client-request-id` 和 `x-codex-installation-id`,不带会话/线程头。 + WebSocket payload metadata: -- 当前项目会在每次 WebSocket 请求发出前向 `client_metadata` 写 `x-codex-ws-stream-request-start-ms`,值为当前 Unix 毫秒字符串;HTTP SSE 不写该字段。 +- 当前项目会在 Responses payload `client_metadata` 写入 `session_id`、`thread_id`、`x-codex-installation-id`、`x-codex-window-id`、`x-codex-turn-metadata`、`x-codex-parent-thread-id`。 +- 当前项目会在每次 WebSocket 请求发出前额外向 `client_metadata` 写 `x-codex-ws-stream-request-start-ms`,值为当前 Unix 毫秒字符串;HTTP SSE 不写该字段。 Desktop 二进制还出现 `x-oai-attestation`、`requestAttestation`、`ws_request_header_traceparent`、`ws_request_header_tracestate`、`x-openai-internal-codex-responses-lite`。这些字段不能按固定值补齐,需要满足各自触发条件。 @@ -422,6 +440,7 @@ Usage 请求路径按 base URL 选择: - `responses_websockets=2026-02-06` beta 头。 - `x-client-request-id`、`x-codex-installation-id`、`x-codex-window-id`、`x-codex-turn-state`、`x-codex-turn-metadata` 的透传。 +- `session-id`、`thread-id` 请求头,以及 payload `client_metadata.session_id`、`client_metadata.thread_id`。 - `previous_response_id` 强制 WebSocket。 - `WebSocketPreferred` 可降级 HTTP SSE,`WebSocketRequired` 不降级。 - `codex.rate_limits` 内部事件不下发给客户端,但用于刷新账号限额状态。 diff --git a/src/upstream/transport/client.rs b/src/upstream/transport/client.rs index 045de7de5..a93d22ee9 100644 --- a/src/upstream/transport/client.rs +++ b/src/upstream/transport/client.rs @@ -747,7 +747,8 @@ impl CodexBackendClient { "x-codex-installation-id", context.installation_id, )?; - insert_optional_header(&mut headers, "session_id", context.session_id)?; + insert_optional_header(&mut headers, "session-id", context.session_id)?; + insert_optional_header(&mut headers, "thread-id", context.session_id)?; insert_optional_header(&mut headers, "x-codex-window-id", context.codex_window_id)?; insert_optional_header(&mut headers, "x-codex-turn-state", context.turn_state)?; insert_optional_header(&mut headers, "x-codex-turn-metadata", context.turn_metadata)?; @@ -1053,6 +1054,8 @@ fn response_client_metadata( "x-codex-installation-id", context.installation_id, ); + insert_metadata_string(&mut metadata, "session_id", context.session_id); + insert_metadata_string(&mut metadata, "thread_id", context.session_id); insert_metadata_string(&mut metadata, "x-codex-window-id", context.codex_window_id); insert_metadata_string( &mut metadata, diff --git a/tests/proxy/dispatch/chat_upstream/responses_http.rs b/tests/proxy/dispatch/chat_upstream/responses_http.rs index 926ab9b5f..9c99eab3e 100644 --- a/tests/proxy/dispatch/chat_upstream/responses_http.rs +++ b/tests/proxy/dispatch/chat_upstream/responses_http.rs @@ -512,6 +512,14 @@ async fn responses_should_forward_parity_fields_context_headers_and_account_scop upstream_body["client_metadata"]["x-codex-installation-id"], TEST_INSTALLATION_ID ); + assert_eq!( + upstream_body["client_metadata"]["session_id"], + conversation_id + ); + assert_eq!( + upstream_body["client_metadata"]["thread_id"], + conversation_id + ); assert_eq!( upstream_body["client_metadata"]["x-codex-window-id"], window_id @@ -524,7 +532,9 @@ async fn responses_should_forward_parity_fields_context_headers_and_account_scop upstream_body["client_metadata"]["x-codex-parent-thread-id"], "parent-metadata" ); - assert_eq!(upstream_header("session_id"), Some(conversation_id)); + assert_eq!(upstream_header("session-id"), Some(conversation_id)); + assert_eq!(upstream_header("thread-id"), Some(conversation_id)); + assert_eq!(upstream_header("session_id"), None); assert_eq!(upstream_header("x-codex-window-id"), Some(window_id)); assert_eq!(upstream_header("x-codex-turn-state"), Some("turn-body")); assert_eq!( diff --git a/tests/upstream/transport/headers.rs b/tests/upstream/transport/headers.rs index e7f0c9ca6..5ebb1ba36 100644 --- a/tests/upstream/transport/headers.rs +++ b/tests/upstream/transport/headers.rs @@ -327,6 +327,8 @@ async fn codex_backend_client_websocket_should_forward_security_chain_headers_an "safe": "yes", "x-openai-subagent": "review", "x-codex-installation-id": "install-123", + "session_id": "cp_derived", + "thread_id": "cp_derived", "x-codex-window-id": "cw_derived", "x-codex-turn-metadata": "{\"thread_source\":\"subagent\"}", "x-codex-parent-thread-id": "parent-456" @@ -366,11 +368,13 @@ async fn codex_backend_client_websocket_should_forward_security_chain_headers_an .any(|(name, value)| name == "x-openai-subagent" && value == "review")); assert!(headers .iter() - .any(|(name, value)| name == "session_id" && value == "cp_derived")); + .any(|(name, value)| name == "session-id" && value == "cp_derived")); + assert!(headers + .iter() + .any(|(name, value)| name == "thread-id" && value == "cp_derived")); assert!(headers.iter().all(|(name, _)| name != "content-type")); assert!(headers.iter().all(|(name, _)| name != "accept")); - assert!(headers.iter().all(|(name, _)| name != "session-id")); - assert!(headers.iter().all(|(name, _)| name != "thread-id")); + assert!(headers.iter().all(|(name, _)| name != "session_id")); } #[tokio::test] @@ -547,6 +551,8 @@ async fn codex_backend_client_usage_should_use_wham_usage_headers() { assert!(headers.get("x-client-request-id").is_none()); assert!(headers.get("x-codex-installation-id").is_none()); assert!(headers.get("session_id").is_none()); + assert!(headers.get("session-id").is_none()); + assert!(headers.get("thread-id").is_none()); } #[tokio::test] @@ -602,6 +608,8 @@ async fn codex_backend_client_models_should_use_original_auxiliary_headers() { Some("install-1") ); assert!(headers.get("session_id").is_none()); + assert!(headers.get("session-id").is_none()); + assert!(headers.get("thread-id").is_none()); } #[tokio::test] @@ -680,7 +688,8 @@ async fn codex_backend_client_should_send_http_sse_headers_in_fingerprint_order( "x-openai-internal-codex-residency", "x-client-request-id", "x-codex-installation-id", - "session_id", + "session-id", + "thread-id", "x-codex-window-id", "x-codex-turn-state", "x-codex-turn-metadata",