diff --git a/backend/crates/providers/openai/src/transport/correlation.rs b/backend/crates/providers/openai/src/transport/correlation.rs index 0a639e5f8..44b216317 100644 --- a/backend/crates/providers/openai/src/transport/correlation.rs +++ b/backend/crates/providers/openai/src/transport/correlation.rs @@ -7,21 +7,18 @@ use sha2::{Digest, Sha256}; use super::automatic_context::{locate_contexts, rewrite_git_context}; use super::request::serialize_ascii_turn_metadata; +// 轮次及父子轮次引用保留客户端原值,避免改变官方轮次身份与 UUIDv7 语义 const ID_FIELDS: &[&str] = &[ "session_id", "thread_id", - "turn_id", "window_id", "context_window_id", "parent_thread_id", - "parent_turn_id", - "root_turn_id", "forked_from_thread_id", "conversation_id", "x-codex-window-id", "x-codex-parent-thread-id", "x-client-request-id", - "x-client-turn-id", "codexWindowId", "parentThreadId", ]; @@ -39,8 +36,6 @@ const ID_HEADERS: &[&str] = &[ "x-codex-window-id", "x-codex-parent-thread-id", "x-client-request-id", - "x-client-turn-id", - "x-codex-image-turn-id", ]; fn scoped_id(installation_id: &str, original: &str) -> String { @@ -206,16 +201,6 @@ pub(super) fn scope_request( let Some(installation_id) = installation_id else { return; }; - // 输入项的轮次引用与请求 metadata 共用账号域,保留 item/call ID 和业务元数据 - if let Some(Value::Array(input)) = body.get_mut("input") { - for item in input { - if let Some(Value::Object(metadata)) = - item.get_mut("internal_chat_message_metadata_passthrough") - { - map_fields(metadata, installation_id, &["turn_id"]); - } - } - } // 只处理官方自动 Git 上下文的关联字段,不扫描普通用户输入和工具输出 let Ok(targets) = locate_contexts(body, true, || Ok(())) else { return; @@ -229,11 +214,7 @@ pub(super) fn scope_request( } let _ = rewrite_git_context(text, 1024 * 1024, |value| { Ok(value.as_object_mut().map_or(0, |object| { - map_fields( - object, - installation_id, - &["sessionId", "threadId", "clientUserMessageId"], - ) + map_fields(object, installation_id, &["sessionId", "threadId"]) })) }); } diff --git a/backend/crates/providers/openai/tests/provider/contract/correlation.rs b/backend/crates/providers/openai/tests/provider/contract/correlation.rs index 652654304..58889c1e5 100644 --- a/backend/crates/providers/openai/tests/provider/contract/correlation.rs +++ b/backend/crates/providers/openai/tests/provider/contract/correlation.rs @@ -1,4 +1,4 @@ -//! 账号追踪域的稳定性、HTTP/WS 一致性、父子引用与工具端点归属回归 +//! 账号追踪域的稳定性、轮次原值透传、HTTP/WS 一致性与工具端点归属回归 use super::*; @@ -39,6 +39,7 @@ fn payload(websocket: bool) -> ProtocolPayload { "model":"gpt-5.4", "input":input, "prompt_cache_key":"custom-cache-key", "client_metadata":{ "session_id":"source-session", "thread_id":"source-thread", "turn_id":"source-turn", + "x-client-turn-id":"source-turn", "x-codex-window-id":"source-window", "x-codex-parent-thread-id":"source-session", "x-codex-turn-metadata":full.to_string(), "ws_request_header_traceparent":"00-11111111111111111111111111111111-2222222222222222-01", @@ -50,6 +51,7 @@ fn payload(websocket: bool) -> ProtocolPayload { ("thread_id".into(), json!("source-thread")), ("turn_metadata".into(), json!(metadata().to_string())), ("opaque_request_headers".into(), json!([ + ["x-client-turn-id",STANDARD.encode("source-turn")], ["traceparent",STANDARD.encode("00-11111111111111111111111111111111-2222222222222222-01")], ["tracestate",STANDARD.encode("vendor=source")] ])), @@ -57,7 +59,7 @@ fn payload(websocket: bool) -> ProtocolPayload { } #[tokio::test] -async fn account_correlation_is_stable_across_transports_and_changes_only_with_account() { +async fn account_correlation_preserves_turns_across_transports_and_account_switches() { let stores = [ Arc::new(MemoryAccountStore::default()), Arc::new(MemoryAccountStore::default()), @@ -140,7 +142,6 @@ async fn account_correlation_is_stable_across_transports_and_changes_only_with_a for (field, original) in [ ("session_id", "source-session"), ("thread_id", "source-thread"), - ("turn_id", "source-turn"), ] { assert_scoped_id(flat[field].as_str().unwrap(), original); assert_eq!(flat[field], header[field]); @@ -148,10 +149,16 @@ async fn account_correlation_is_stable_across_transports_and_changes_only_with_a } assert_eq!(headers["session-id"].to_str().unwrap(), flat["session_id"]); assert_eq!(headers["thread-id"].to_str().unwrap(), flat["thread_id"]); + assert_eq!(headers["x-client-turn-id"], "source-turn"); + assert_eq!(flat["x-client-turn-id"], "source-turn"); + assert_eq!(flat["turn_id"], "source-turn"); + for metadata in [&header, &full] { + assert_eq!(metadata["turn_id"], "source-turn"); + assert_eq!(metadata["parent_turn_id"], "source-turn"); + assert_eq!(metadata["root_turn_id"], "source-turn"); + } assert_eq!(full["parent_thread_id"], flat["session_id"]); assert_eq!(full["forked_from_thread_id"], flat["session_id"]); - assert_eq!(full["parent_turn_id"], flat["turn_id"]); - assert_eq!(full["root_turn_id"], flat["turn_id"]); assert_eq!(full["window_id"], full["context_window_id"]); assert!(header.get("tool_namespaces_info").is_none()); assert_eq!(full["tool_namespaces_info"]["inventory"], "body-only"); @@ -162,7 +169,7 @@ async fn account_correlation_is_stable_across_transports_and_changes_only_with_a json!({"turn_id":flat["turn_id"], "content_item_kinds":["additional_content.codex_git_state"]}), ); let previous_turn = &input[1]["internal_chat_message_metadata_passthrough"]["turn_id"]; - assert_scoped_id(previous_turn.as_str().unwrap(), "source-previous-turn"); + assert_eq!(previous_turn, "source-previous-turn"); assert_ne!(previous_turn, &flat["turn_id"]); let mut expected_history = account_isolation::isolation_history(); for (index, item) in expected_history @@ -176,11 +183,11 @@ async fn account_correlation_is_stable_across_transports_and_changes_only_with_a .unwrap() .entry("internal_chat_message_metadata_passthrough") .or_insert_with(|| json!({})); - metadata["turn_id"] = if index == 3 { - flat["turn_id"].clone() + metadata["turn_id"] = json!(if index == 3 { + "source-turn" } else { - previous_turn.clone() - }; + "source-previous-turn" + }); } assert_eq!(json!(&input[1..]), expected_history); let document = @@ -224,11 +231,11 @@ async fn account_correlation_is_stable_across_transports_and_changes_only_with_a assert_eq!(captures[4], captures[5]); assert_ne!(captures[0].0, captures[4].0); assert_ne!(captures[0].1, captures[4].1); - assert_ne!(captures[0].2, captures[4].2); + assert_eq!(captures[0].2, captures[4].2); } #[tokio::test] -async fn image_generation_and_edit_share_the_selected_account_turn_identity() { +async fn image_generation_and_edit_preserve_the_client_turn_identity() { let store = Arc::new(MemoryAccountStore::default()); create_account(&store, "acct_provider_contract").await; let server = MockServer::start().await; @@ -263,7 +270,7 @@ async fn image_generation_and_edit_share_the_selected_account_turn_identity() { } let requests = server.received_requests().await.unwrap(); let first = &requests[0].headers["x-codex-image-turn-id"]; - assert_scoped_id(first.to_str().unwrap(), "source-turn"); + assert_eq!(first, "source-turn"); for request in &requests { assert_eq!(request.body.as_slice(), body.as_ref()); assert_eq!(&request.headers["x-codex-image-turn-id"], first); diff --git a/backend/crates/providers/openai/tests/provider/contract/mod.rs b/backend/crates/providers/openai/tests/provider/contract/mod.rs index 8bfcc6c0c..d6b86a591 100644 --- a/backend/crates/providers/openai/tests/provider/contract/mod.rs +++ b/backend/crates/providers/openai/tests/provider/contract/mod.rs @@ -2789,13 +2789,7 @@ async fn image_endpoints_bypass_only_the_text_catalog_and_preserve_the_current_c Mock::given(method("POST")) .and(path(*endpoint)) .and(header("originator", "codex_cli_rs")) - .and(|request: &wiremock::Request| { - request - .headers - .get("x-codex-image-turn-id") - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| uuid::Uuid::parse_str(value).is_ok()) - }) + .and(header("x-codex-image-turn-id", "turn_image_contract")) .and(header("version", "0.144.0")) .and(header("accept", "*/*")) .and(body_bytes(body.to_vec())) @@ -3636,7 +3630,7 @@ async fn standalone_search_preserves_wire_and_scopes_turn_metadata_to_the_select let metadata: Value = serde_json::from_slice(&metadata_values[0]).expect("turn metadata JSON"); assert_scoped_id(metadata["session_id"].as_str().unwrap(), "session"); assert_scoped_id(metadata["thread_id"].as_str().unwrap(), "thread"); - assert_scoped_id(metadata["turn_id"].as_str().unwrap(), "turn"); + assert_eq!(metadata["turn_id"], "turn"); assert_eq!(metadata.get("future"), Some(&json!(true))); assert!(metadata.get("account_id").is_none()); assert_ne!( diff --git a/docs/api.md b/docs/api.md index 724723548..325e19aa1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -298,13 +298,13 @@ Responses 也不透传 `x-stainless-*`、`Origin`、`Referer`、`sec-ch-ua*` 和 出站移除下游的 `traceparent`、`tracestate` 及 WS metadata 中对应的 `ws_request_header_traceparent`、`ws_request_header_tracestate` -会话、线程、轮次、窗口及父子引用在出站时按账号安装身份稳定映射为 UUID; +会话、线程、窗口及父子线程引用在出站时按账号安装身份稳定映射为 UUID; 同账号保留引用关系,换账号使用不同标识。HTTP 请求头、正文 metadata、WS 副本及 已识别的 Desktop 自动 Git 上下文使用一致映射。 -输入项的 `internal_chat_message_metadata_passthrough.turn_id` 同步映射, -保留历史轮次与当前轮次的引用关系,其余输入项元数据保持不变。 -Images 的 `x-codex-image-turn-id`、turn metadata 和 Search 的会话 `id` 使用同一账号域; -Search 只替换该 `id` 的原始字节,其余正文保持不变。 +轮次及父子轮次引用保留客户端原值,包括输入项的 `internal_chat_message_metadata_passthrough.turn_id`、 +请求 metadata 中的 `turn_id`、`parent_turn_id`、`root_turn_id`,以及 `x-client-turn-id`、 +Images 的 `x-codex-image-turn-id` 和 Desktop 自动 Git 上下文的 `clientUserMessageId`。 +输入项元数据保持不变。Search 的会话 `id` 按同一账号域映射,只替换该值的原始字节,其余正文保持不变。 `prompt_cache_key` 同样按账号映射,保留同账号内的缓存共享关系;未提供时不补造。 续接 response ID、历史 item/call ID、业务正文和未知扩展不参与映射, 该处理减少显式追踪标识的跨账号复用,不保证会话内容或业务 metadata 无法关联