From de599ed76846554290e77b038dbbcf16b00e8ff7 Mon Sep 17 00:00:00 2001 From: Zyy Date: Thu, 8 Oct 2026 11:16:31 +0800 Subject: [PATCH] fix(openai): apply timezone overrides without content kinds Recognize complete Codex environment contexts when custom providers omit content classifications, while keeping explicit classifications authoritative. Cover HTTP and WebSocket overrides and proxy location precedence, and document timezone and client privacy boundaries. Refs #366 Co-authored-by: Codex --- .../providers/openai/src/transport/request.rs | 11 +-- .../openai/tests/provider/contract/mod.rs | 62 ++++++++++++- .../openai/tests/transport/request.rs | 91 ++++++++++++++++++- deploy/README.md | 33 ++++++- docs/api.md | 18 +++- 5 files changed, 203 insertions(+), 12 deletions(-) diff --git a/backend/crates/providers/openai/src/transport/request.rs b/backend/crates/providers/openai/src/transport/request.rs index af338662a..53e311bb9 100644 --- a/backend/crates/providers/openai/src/transport/request.rs +++ b/backend/crates/providers/openai/src/transport/request.rs @@ -130,7 +130,7 @@ pub(crate) fn align_structured_location_fields( now: DateTime, location: &CodexRequestLocation, ) { - // 只改写带环境标记的日期和时区;epoch 时间戳保持绝对时间原值 + // 只改写完整环境上下文的日期和时区;epoch 时间戳保持绝对时间原值 let current_date = now .with_timezone(&location.timezone) .format("%Y-%m-%d") @@ -169,11 +169,10 @@ fn align_environment_context(item: &mut Value, current_date: &str, timezone: &st return; }; for (index, part) in content.iter_mut().enumerate() { - if content_kinds - .as_ref() - .and_then(|kinds| kinds.get(index)) - .and_then(Option::as_deref) - != Some(ENVIRONMENT_CONTEXT_CONTENT_KIND) + // 官方自定义 Provider 会移除内容分类,缺省时仍按完整环境上下文识别 + // 已有分类保持权威,不能覆盖显式标为普通文本或其他上下文的内容 + if let Some(kinds) = &content_kinds + && kinds.get(index).and_then(Option::as_deref) != Some(ENVIRONMENT_CONTEXT_CONTENT_KIND) { continue; } diff --git a/backend/crates/providers/openai/tests/provider/contract/mod.rs b/backend/crates/providers/openai/tests/provider/contract/mod.rs index 425964542..e1321b09c 100644 --- a/backend/crates/providers/openai/tests/provider/contract/mod.rs +++ b/backend/crates/providers/openai/tests/provider/contract/mod.rs @@ -773,7 +773,8 @@ async fn selected_proxy_location_overrides_global_and_reloads_without_mutating_c let provider = provider_with_base_url(&store, "http://upstream.invalid".to_owned()); let original = json!({"model":"gpt-5.4", "input":[ {"role":"user", "content":[{"type":"input_text", "text":"UTC"}], "internal_chat_message_metadata_passthrough":{"content_item_kinds":["environments.environment_context"], "create_time":1789293131.822}}, - {"role":"user", "content":[{"type":"input_text", "text":"UTC"}], "internal_chat_message_metadata_passthrough":{"content_item_kinds":["user.text"]}} + {"role":"user", "content":[{"type":"input_text", "text":"UTC"}], "internal_chat_message_metadata_passthrough":{"content_item_kinds":["user.text"]}}, + {"role":"user", "content":[{"type":"input_text", "text":"UTC"}]} ], "tools":[{"type":"web_search"}]}); let operation = Operation::Generate(GenerateRequest::from_protocol_payload( ProtocolPayload::json_object("openai", original.as_object().unwrap().clone()) @@ -797,6 +798,7 @@ async fn selected_proxy_location_overrides_global_and_reloads_without_mutating_c let expected = expected.unwrap_or("UTC"); assert_eq!(body.pointer("/input/0/content/0/text"), Some(&json!(format!("{expected}")))); assert_eq!(body.pointer("/input/1/content/0/text"), original.pointer("/input/1/content/0/text")); + assert_eq!(body.pointer("/input/2/content/0/text"), body.pointer("/input/0/content/0/text")); assert_eq!(body.pointer("/input/0/internal_chat_message_metadata_passthrough/create_time"), Some(&json!(1789293131.822))); } assert_eq!(first_proxy.received_requests().await.unwrap().len(), 2); @@ -1875,6 +1877,64 @@ async fn provider_should_send_the_request_snapshot_location_to_the_upstream() { ); } +#[tokio::test] +async fn provider_should_override_unclassified_environment_timezone_over_websocket() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_provider_contract").await; + let listener = TcpListener::bind("127.0.0.1:0").await.expect("listener"); + let base_url = format!("http://{}", listener.local_addr().expect("address")); + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.expect("accept websocket"); + let mut socket = accept_codex_test_websocket(socket).await; + let message = socket.next().await.expect("request").expect("valid frame"); + let body: Value = serde_json::from_str(message.to_text().expect("text")).expect("JSON"); + socket.send(Message::Text(json!({ + "type": "response.completed", + "response": {"id": "resp_location", "model": "gpt-5.4", "status": "completed", "output": []} + }).to_string().into())).await.expect("complete response"); + body + }); + let environment = "/home/example/项目Asia/Shanghai"; + let payload = ProtocolPayload::json_object( + "openai", + json!({ + "model": "gpt-5.4", + "input": [{"type": "message", "role": "user", "content": [{"type": "input_text", "text": environment}]}], + "tools": [{"type": "web_search"}] + }).as_object().expect("request object").clone(), + ) + .expect("payload") + .with_context(Map::from_iter([("use_websocket".to_owned(), json!(true))])); + let mut stream = provider_with_base_url(&store, base_url) + .execute( + planned_request( + "openai", + Operation::Generate(GenerateRequest::from_protocol_payload(payload)), + ), + context_with_state_owner("req_ws_location", "acct_provider_contract"), + ) + .await + .expect("provider stream"); + while let Some(event) = stream.next().await { + event.expect("successful websocket response"); + } + let body = server.await.expect("server"); + assert_eq!( + body.pointer("/input/0/content/0/text"), + Some(&json!( + environment.replace("Asia/Shanghai", "Pacific/Auckland") + )) + ); + assert_eq!( + body.pointer("/tools/0/user_location/timezone"), + Some(&json!("Pacific/Auckland")) + ); + assert!( + body.pointer("/input/0/internal_chat_message_metadata_passthrough") + .is_none() + ); +} + async fn capture_turn_state_request( request_id: &str, previous_turn_id: Option<&str>, diff --git a/backend/crates/providers/openai/tests/transport/request.rs b/backend/crates/providers/openai/tests/transport/request.rs index 6acb7e8b7..75539e85d 100644 --- a/backend/crates/providers/openai/tests/transport/request.rs +++ b/backend/crates/providers/openai/tests/transport/request.rs @@ -94,6 +94,92 @@ fn encoder_should_preserve_location_fields_when_no_override_is_configured() { assert_eq!(encoded.body().get("model"), Some(&json!("gpt-test"))); } +#[test] +fn encoder_should_align_official_environment_context_without_content_kinds() { + let environment = "\n \n \ + /home/example/projectbash\n \ + \n 2020-01-01\n \ + Asia/Shanghai\n"; + for metadata in [None, Some(json!({"create_time": 1789293131.822}))] { + let mut message = json!({ + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "Please explain the current environment"}, + {"type": "input_text", "text": environment} + ] + }); + if let Some(metadata) = metadata { + message["internal_chat_message_metadata_passthrough"] = metadata; + } + let body = json!({"input": [message]}) + .as_object() + .expect("request object") + .clone(); + let before = Utc::now() + .with_timezone(&New_York) + .format("%Y-%m-%d") + .to_string(); + let encoded = encode_generate_request( + &request(body.clone()), + "gpt-test", + Some(&CodexRequestLocation::default()), + ) + .expect("encode unclassified official context"); + let after = Utc::now() + .with_timezone(&New_York) + .format("%Y-%m-%d") + .to_string(); + let encoded = Value::Object(encoded.body().clone()); + let aligned = encoded + .pointer("/input/0/content/1/text") + .and_then(Value::as_str) + .expect("aligned environment"); + assert!(aligned.contains("America/New_York")); + assert!([before, after].iter().any(|date| { + aligned + == environment + .replace("2020-01-01", date) + .replace("Asia/Shanghai", "America/New_York") + })); + let original = Value::Object(body.clone()); + assert_eq!( + encoded.pointer("/input/0/content/0"), + original.pointer("/input/0/content/0") + ); + assert_eq!( + encoded.pointer("/input/0/internal_chat_message_metadata_passthrough"), + original.pointer("/input/0/internal_chat_message_metadata_passthrough") + ); + + let unchanged = encode_generate_request(&request(body.clone()), "gpt-test", None) + .expect("encode without override"); + assert_eq!(unchanged.body().get("input"), body.get("input")); + } +} + +#[test] +fn encoder_should_preserve_classified_text_and_non_user_environment_items() { + let environment = + "Asia/Shanghai"; + let input = json!([ + {"role": "user", "content": [{"type": "input_text", "text": environment}], "internal_chat_message_metadata_passthrough": {"content_item_kinds": ["user.text"]}}, + {"role": "user", "content": [{"type": "input_text", "text": environment}], "internal_chat_message_metadata_passthrough": {"content_item_kinds": ["future.context"]}}, + {"role": "user", "content": [{"type": "input_text", "text": environment}], "internal_chat_message_metadata_passthrough": {"content_item_kinds": []}}, + {"role": "developer", "content": [{"type": "input_text", "text": environment}]}, + {"role": "assistant", "content": [{"type": "output_text", "text": environment}]}, + {"type": "function_call_output", "call_id": "synthetic", "output": environment}, + {"role": "user", "content": [{"type": "input_text", "text": "Asia/Shanghai"}]} + ]); + let encoded = encode_generate_request( + &request(Map::from_iter([("input".to_owned(), input.clone())])), + "gpt-test", + Some(&CodexRequestLocation::default()), + ) + .expect("encode without changing non-environment content"); + assert_eq!(encoded.body().get("input"), Some(&input)); +} + #[test] fn encoder_should_preserve_openai_wire_fields_without_deriving_accountless_pool_identity() { let body = Map::from_iter([ @@ -185,6 +271,7 @@ fn encoder_should_patch_model_and_preserve_supported_generate_semantics() { fn encoder_should_align_structured_location_fields_without_rewriting_chat_text() { let normal_chat = "\n 2026-09-13\n \ Asia/Shanghai\n"; + let quoted_context = format!("Please explain this example:\n```xml\n{normal_chat}\n```"); let body = json!({ "model": "client-model", "input": [ @@ -211,7 +298,7 @@ fn encoder_should_align_structured_location_fields_without_rewriting_chat_text() { "type": "message", "role": "user", - "content": [{"type": "input_text", "text": normal_chat}] + "content": [{"type": "input_text", "text": quoted_context}] } ], "tools": [ @@ -281,7 +368,7 @@ fn encoder_should_align_structured_location_fields_without_rewriting_chat_text() ); assert_eq!( encoded.pointer("/input/2/content/0/text"), - Some(&json!(normal_chat)) + Some(&json!(quoted_context)) ); assert_eq!( encoded.pointer("/input/0/internal_chat_message_metadata_passthrough/create_time"), diff --git a/deploy/README.md b/deploy/README.md index 6c12f9b24..7fc81472c 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -7,7 +7,7 @@ | 任务 | 入口 | | --- | --- | | 安装与启动 | [手动安装](#手动安装) · [启动](#启动) · [公网访问](#公网访问) | -| 接入客户端 | [客户端配置](#客户端配置) · [登录与生图排查](#登录与生图排查) | +| 接入客户端 | [客户端配置](#客户端配置) · [时区与客户端环境信息](#时区与客户端环境信息) · [登录与生图排查](#登录与生图排查) | | 日常运维 | [运行设置](#启动后的运行设置) · [小内存优化](#小内存优化) · [持久化与日志](#持久化与备份) · [请求排查](#请求错误排查) · [密码轮换](#密码语义) | | 更新与恢复 | [镜像升级](#镜像升级与源码构建) · [在线更新](#管理端在线更新) · [备份与恢复](#备份与恢复) · [优雅关停](#优雅关停) | | 运行源码 | [开发与源码联调](../docs/development.md) | @@ -362,6 +362,37 @@ OpenAI OAuth 账号的上游传输方式默认 WS;固定为 SSE 的账号不 `image_generation` 和 `goals` 仍有效。不要为了接入代理,顺带扩大命令沙箱的联网或文件权限 +### 时区与客户端环境信息 + +要覆盖模型请求中的位置和时区,在管理端启用全局自定义位置,或为账号关联的代理设置位置; +代理位置优先。`host.timezone` 控制部署日历和管理端时间展示,不代替请求位置设置。 +覆盖字段与环境消息识别条件见 [代理位置合同](../docs/api.md#独立代理管理--managed-proxies) + +Codex 回答仍显示本机时区时,先核对请求是否使用了预期账号和代理,再区分环境正文与工具结果: + +- 使用新会话询问环境上下文中的时区,避免已有工具输出或历史回答影响判断 +- 检查实际环境消息是否满足完整上下文形状;显式普通文本分类或非法 XML 不会被覆盖 +- 若模型调用 `date` 等工具查询本机,工具结果仍反映真实系统时区,覆盖不会修改客户端操作系统 + +网关的请求头画像和位置覆盖不提供完整匿名化。正文与工作区 metadata 的保留范围见 +[正文兼容](../docs/api.md#正文兼容),xAI 请求不应用 OpenAI 的请求位置覆盖 + +Codex 客户端还有独立的出站请求。analytics 使用 `chatgpt_base_url`,可以包含真实系统类型、 +系统版本和架构;模型 Provider 的 `base_url` 不改变该地址。已核对的官方 release 配置默认启用 +Statsig 指标,目标为 `https://ab.chatgpt.com/otlp/v1/metrics`,不由网关上游画像控制 + +需要关闭这两类遥测时,将以下字段合并到客户端 `config.toml`,避免重复创建已有表,完全退出并重启 Codex: + +```toml +[analytics] +enabled = false + +[otel] +metrics_exporter = "none" +``` + +这两个开关不清理模型请求中的工作目录、Git metadata、用户内容或工具结果。 + ### 登录与生图排查 - 仍提示登录:确认实际读取的用户配置目录、选中的 Provider 和客户端版本,再完全退出重启。 diff --git a/docs/api.md b/docs/api.md index 2b644f963..ad9de2b57 100644 --- a/docs/api.md +++ b/docs/api.md @@ -295,6 +295,10 @@ Codex/OAuth 上游的历史回填按字段形状兼容,不以 User-Agent 品 请求头过滤不提供客户端匿名化;系统提示词、工具定义、工具结果、工作目录及其他业务 metadata 保持原有语义,可能包含客户端环境信息。 +环境正文中的工作区路径、shell 和文件权限信息不会因位置覆盖而隐藏。 +turn metadata 的 `workspaces` 也会保留仓库绝对路径、Git 远端地址、提交及工作区变更状态; +安装身份和账号绑定字段的处理不提供这些信息的脱敏 + `client_metadata.parent_response_id` 是 Guardian 的账号内响应引用,只有归属可信且仍为同一账号时保留; 切号或归属未知时移除。`x-codex-guardian`、`guardian_credits_requested` 和序列化 `x-codex-turn-metadata` 内普通扩展的同名 `parent_response_id` 保持原样 @@ -755,11 +759,21 @@ API 的 `autoLocation` 默认为 `false`;开启时使用已检测位置,测 关联账号的 OpenAI/Codex Responses 请求(HTTP/SSE、WebSocket)优先使用代理位置,否则使用全局 运行设置中已开启的 `requestLocation`;两者均未开启时保留客户端原有位置和时区。全局覆盖按请求冻结, 新请求使用保存后的设置,无需重启;代理覆盖在每次执行时读取, -换号或换出口按该次选定账号解析。位置只影响带来源标记的环境上下文日期/时区和 Web Search 的结构化位置, -不改变用户普通文本、epoch 时间戳、真实出口 IP、服务或管理端时区、数据驻留约束及 xAI 请求 +换号或换出口按该次选定账号解析。位置只影响环境上下文日期/时区和 Web Search 的结构化位置, +不改变 epoch 时间戳、真实出口 IP、服务或管理端时区、数据驻留约束及 xAI 请求 + +环境消息要求 `role: "user"`,文本块为 `type: "input_text"`,完整文本去除首尾空白后由 +`` 与 `` 包围且为合法 XML;只替换根节点直属的 +`current_date` 和 `timezone`。有 `internal_chat_message_metadata_passthrough.content_item_kinds` 数组时, +仅处理对应分类为 `environments.environment_context` 的文本块;没有分类时,按完整环境上下文识别。 +显式标为 `user.text` 或其他分类的内容、普通聊天中引用的示例、工具结果及无法解析的上下文保持原样。 +客户端实际本机时区不受影响,工具读取本机时区后的输出仍可包含真实值;排查见 +[时区与客户端环境信息](../deploy/README.md#时区与客户端环境信息) 测试经代理并发访问 IPv4 专用端点 `https://api.ipify.org?format=json` 与 IPv6 专用端点 `https://api6.ipify.org?format=json`, 分别验证并记录双栈出口(IPv4 与 IPv6 地址),在任一地址族可用时即判定连接成功。超时 15 秒,每进程最多同时测试 4 条。 +解析位置时还会向 `https://ipwho.is/<出口 IP>` 查询地理位置,出口 IP 会发送给该第三方服务; +自定义位置和时区不改变这些探测请求 探测器复用 OpenAI 的证书信任配置:优先读取非空的 `CODEX_CA_CERTIFICATE`, 其次读取 `SSL_CERT_FILE`,并保留系统根证书;证书配置错误不会回退为不验证证书。 出口测试结果仅供诊断,不限制代理的选择和绑定;未测试或测试失败的代理仍可使用。