diff --git a/backend/crates/providers/openai/src/transport/request.rs b/backend/crates/providers/openai/src/transport/request.rs index d36d43fe3..5f0ed91e7 100644 --- a/backend/crates/providers/openai/src/transport/request.rs +++ b/backend/crates/providers/openai/src/transport/request.rs @@ -26,6 +26,7 @@ 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 DESKTOP_TIME_CONTEXT_CONTENT_KIND: &str = "additional_content.codex_apps_client_time_context"; const CROSS_ACCOUNT_IDENTITY_KEYS: &[&str] = &[ "authorization", @@ -130,14 +131,14 @@ pub(crate) fn align_structured_location_fields( now: DateTime, location: &CodexRequestLocation, ) { - // 只改写完整环境上下文的日期和时区;epoch 时间戳保持绝对时间原值 + // 环境与 Desktop 时间上下文使用同一位置;epoch 时间戳保持绝对时间原值 let current_date = now .with_timezone(&location.timezone) .format("%Y-%m-%d") .to_string(); if let Some(input) = body.get_mut("input").and_then(Value::as_array_mut) { for item in input { - align_environment_context(item, ¤t_date, location.timezone.name()); + align_time_context(item, ¤t_date, location.timezone.name()); } } if let Some(tools) = body.get_mut("tools").and_then(Value::as_array_mut) { @@ -147,13 +148,19 @@ pub(crate) fn align_structured_location_fields( } } -fn align_environment_context(item: &mut Value, current_date: &str, timezone: &str) { +fn align_time_context(item: &mut Value, current_date: &str, timezone: &str) { let Some(item) = item.as_object_mut() else { return; }; - if item.get("role").and_then(Value::as_str) != Some("user") { - return; - } + // Desktop 将客户端时间放入独立 developer 上下文,不能只覆盖执行环境的 user 消息 + let (context_tag, content_kind) = match item.get("role").and_then(Value::as_str) { + Some("user") => ("environment_context", ENVIRONMENT_CONTEXT_CONTENT_KIND), + Some("developer") => ( + "codex_apps_client_time_context", + DESKTOP_TIME_CONTEXT_CONTENT_KIND, + ), + _ => return, + }; let content_kinds = item .get("internal_chat_message_metadata_passthrough") .and_then(Value::as_object) @@ -169,10 +176,10 @@ fn align_environment_context(item: &mut Value, current_date: &str, timezone: &st return; }; for (index, part) in content.iter_mut().enumerate() { - // 官方自定义 Provider 会移除内容分类,缺省时仍按完整环境上下文识别 + // 官方自定义 Provider 会移除内容分类,缺省时仍按角色与完整上下文识别 // 已有分类保持权威,不能覆盖显式标为普通文本或其他上下文的内容 if let Some(kinds) = &content_kinds - && kinds.get(index).and_then(Option::as_deref) != Some(ENVIRONMENT_CONTEXT_CONTENT_KIND) + && kinds.get(index).and_then(Option::as_deref) != Some(content_kind) { continue; } @@ -185,21 +192,27 @@ fn align_environment_context(item: &mut Value, current_date: &str, timezone: &st let Some(Value::String(text)) = part.get_mut("text") else { continue; }; - if let Some(aligned) = aligned_environment_context(text, current_date, timezone) { + if let Some(aligned) = aligned_time_context(text, context_tag, current_date, timezone) { *text = aligned; } } } -fn aligned_environment_context(text: &str, current_date: &str, timezone: &str) -> Option { +fn aligned_time_context( + text: &str, + context_tag: &str, + current_date: &str, + timezone: &str, +) -> Option { let trimmed = text.trim(); - if !trimmed.starts_with("") || !trimmed.ends_with("") + if !trimmed.starts_with(&format!("<{context_tag}>")) + || !trimmed.ends_with(&format!("")) { return None; } let document = Document::parse(text).ok()?; let root = document.root_element(); - if !root.has_tag_name("environment_context") { + if !root.has_tag_name(context_tag) { return None; } let mut replacements = root diff --git a/backend/crates/providers/openai/tests/provider/contract/mod.rs b/backend/crates/providers/openai/tests/provider/contract/mod.rs index f070bf76f..5ab0c07b7 100644 --- a/backend/crates/providers/openai/tests/provider/contract/mod.rs +++ b/backend/crates/providers/openai/tests/provider/contract/mod.rs @@ -775,7 +775,9 @@ async fn selected_proxy_location_overrides_global_and_reloads_without_mutating_c 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"}]} + {"role":"user", "content":[{"type":"input_text", "text":"UTC"}]}, + {"role":"developer", "content":[{"type":"input_text", "text":"UTC"}], "internal_chat_message_metadata_passthrough":{"content_item_kinds":["additional_content.codex_apps_client_time_context"]}}, + {"role":"developer", "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()) @@ -800,6 +802,8 @@ async fn selected_proxy_location_overrides_global_and_reloads_without_mutating_c 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/3/content/0/text"), Some(&json!(format!("{expected}")))); + assert_eq!(body.pointer("/input/4/content/0/text"), body.pointer("/input/3/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); @@ -1879,7 +1883,7 @@ async fn provider_should_send_the_request_snapshot_location_to_the_upstream() { } #[tokio::test] -async fn provider_should_override_unclassified_environment_timezone_over_websocket() { +async fn provider_should_override_unclassified_time_contexts_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"); @@ -1896,11 +1900,15 @@ async fn provider_should_override_unclassified_environment_timezone_over_websock body }); let environment = "/home/example/项目Asia/Shanghai"; + let desktop = "Asia/Shanghai"; let payload = ProtocolPayload::json_object( "openai", json!({ "model": "gpt-5.4", - "input": [{"type": "message", "role": "user", "content": [{"type": "input_text", "text": environment}]}], + "input": [ + {"type": "message", "role": "user", "content": [{"type": "input_text", "text": environment}]}, + {"type": "message", "role": "developer", "content": [{"type": "input_text", "text": desktop}]} + ], "tools": [{"type": "web_search"}] }).as_object().expect("request object").clone(), ) @@ -1920,6 +1928,10 @@ async fn provider_should_override_unclassified_environment_timezone_over_websock event.expect("successful websocket response"); } let body = server.await.expect("server"); + assert_eq!( + body.pointer("/input/1/content/0/text"), + Some(&json!(desktop.replace("Asia/Shanghai", "Pacific/Auckland"))) + ); assert_eq!( body.pointer("/input/0/content/0/text"), Some(&json!( diff --git a/backend/crates/providers/openai/tests/transport/request.rs b/backend/crates/providers/openai/tests/transport/request.rs index 75539e85d..70828b34f 100644 --- a/backend/crates/providers/openai/tests/transport/request.rs +++ b/backend/crates/providers/openai/tests/transport/request.rs @@ -158,6 +158,102 @@ fn encoder_should_align_official_environment_context_without_content_kinds() { } } +#[test] +fn encoder_should_align_desktop_time_context_with_environment_dates() { + // Desktop Core 0.162.0-alpha.17.2 的应用附加上下文使用 developer role + let desktop = " Asia/Shanghai\n\ + 2020-01-01\n\ + Use this client time context for user-facing dates, times, and schedules instead of the execution host's timezone and current date.\ + \n"; + let environment = "2020-01-01Asia/Shanghai"; + for kinds in [ + None, + Some(json!([ + "developer.text", + "additional_content.codex_apps_client_time_context" + ])), + ] { + let mut message = json!({ + "type": "message", "role": "developer", + "content": [ + {"type": "input_text", "text": "Keep Asia/Shanghai in this instruction"}, + {"type": "input_text", "text": desktop} + ], + "internal_chat_message_metadata_passthrough": {"create_time": 1789293131.822} + }); + if let Some(kinds) = kinds { + message["internal_chat_message_metadata_passthrough"]["content_item_kinds"] = kinds; + } + let body = json!({"input": [ + {"role": "user", "content": [{"type": "input_text", "text": environment}]}, + 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 Desktop time context"); + let after = Utc::now() + .with_timezone(&New_York) + .format("%Y-%m-%d") + .to_string(); + assert!( + [before, after].iter().any(|date| { + let mut expected = body["input"].clone(); + expected[0]["content"][0]["text"] = json!( + environment + .replace("2020-01-01", date) + .replace("Asia/Shanghai", "America/New_York") + ); + expected[1]["content"][1]["text"] = json!( + desktop + .replace("2020-01-01", date) + .replace("Asia/Shanghai", "America/New_York") + ); + encoded.body().get("input") == Some(&expected) + }), + "both time contexts must agree while preserving other content and metadata" + ); + 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_text_outside_desktop_time_context() { + let desktop = "Asia/Shanghai2020-01-01"; + let input = json!([ + {"role": "user", "content": [{"type": "input_text", "text": desktop}]}, + {"role": "assistant", "content": [{"type": "output_text", "text": desktop}]}, + {"type": "function_call_output", "call_id": "synthetic", "output": desktop}, + {"role": "developer", "content": [{"type": "input_text", "text": desktop}], "internal_chat_message_metadata_passthrough": {"content_item_kinds": ["user.text"]}}, + {"role": "developer", "content": [{"type": "input_text", "text": desktop}], "internal_chat_message_metadata_passthrough": {"content_item_kinds": ["environments.environment_context"]}}, + {"role": "developer", "content": [{"type": "input_text", "text": desktop}], "internal_chat_message_metadata_passthrough": {"content_item_kinds": []}}, + {"role": "developer", "content": [{"type": "input_text", "text": format!("Example:\n{desktop}")}]}, + {"role": "developer", "content": [{"type": "input_text", "text": format!("{desktop}\nKeep this example")}]}, + {"role": "developer", "content": [{"type": "input_text", "text": "Asia/Shanghai"}]}, + {"role": "developer", "content": [{"type": "input_text", "text": "Asia/Shanghai"}]}, + {"role": "developer", "content": [{"type": "output_text", "text": desktop}]}, + {"role": "developer", "content": [{"type": "input_text", "text": "Asia/Shanghai2020-01-01"}]} + ]); + let encoded = encode_generate_request( + &request(Map::from_iter([("input".to_owned(), input.clone())])), + "gpt-test", + Some(&CodexRequestLocation::default()), + ) + .expect("encode without changing unrelated content"); + assert_eq!(encoded.body().get("input"), Some(&input)); +} + #[test] fn encoder_should_preserve_classified_text_and_non_user_environment_items() { let environment = diff --git a/deploy/README.md b/deploy/README.md index f9ebcd4f2..8a5c3bf3a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -363,10 +363,10 @@ OpenAI OAuth 账号的上游传输方式默认 WS;固定为 SSE 的账号不 代理位置优先。`host.timezone` 控制部署日历和管理端时间展示,不代替请求位置设置。 覆盖字段与环境消息识别条件见 [代理位置合同](../docs/api.md#独立代理管理--managed-proxies) -Codex 回答仍显示本机时区时,先核对请求是否使用了预期账号和代理,再区分环境正文与工具结果: +Codex 回答仍显示本机时区时,先核对请求是否使用了预期账号和代理,再区分时间上下文与工具结果: - 使用新会话询问环境上下文中的时区,避免已有工具输出或历史回答影响判断 -- 检查实际环境消息是否满足完整上下文形状;显式普通文本分类或非法 XML 不会被覆盖 +- 检查 `environment_context` 和 Desktop 的 `codex_apps_client_time_context` 是否满足[时间上下文识别条件](../docs/api.md#独立代理管理--managed-proxies);显式普通文本分类或非法 XML 不会被覆盖 - 若模型调用 `date` 等工具查询本机,工具结果仍反映真实系统时区,覆盖不会修改客户端操作系统 网关的请求头画像和位置覆盖不提供完整匿名化。正文与工作区 metadata 的保留范围见 diff --git a/docs/api.md b/docs/api.md index 9d23782d7..313dd40ed 100644 --- a/docs/api.md +++ b/docs/api.md @@ -770,13 +770,19 @@ API 的 `autoLocation` 默认为 `false`;开启时使用已检测位置,测 关联账号的 OpenAI/Codex Responses 请求(HTTP/SSE、WebSocket)优先使用代理位置,否则使用全局 运行设置中已开启的 `requestLocation`;两者均未开启时保留客户端原有位置和时区。全局覆盖按请求冻结, 新请求使用保存后的设置,无需重启;代理覆盖在每次执行时读取, -换号或换出口按该次选定账号解析。位置只影响环境上下文日期/时区和 Web Search 的结构化位置, +换号或换出口按该次选定账号解析。位置只影响客户端时间上下文的日期/时区和 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` 的文本块;没有分类时,按完整环境上下文识别。 +时间上下文支持以下两种消息,文本块均为 `type: "input_text"`: + +| 消息角色 | 完整 XML 根标签 | 内容分类 | +| --- | --- | --- | +| `user` | `environment_context` | `environments.environment_context` | +| `developer` | `codex_apps_client_time_context` | `additional_content.codex_apps_client_time_context` | + +文本去除首尾空白后须由对应标签完整包围且为合法 XML,只替换根节点直属的 `current_date` 和 `timezone`。 +有 `internal_chat_message_metadata_passthrough.content_item_kinds` 数组时,文本块的对应分类须匹配上表; +没有分类时,按消息角色和完整 XML 识别。 显式标为 `user.text` 或其他分类的内容、普通聊天中引用的示例、工具结果及无法解析的上下文保持原样。 客户端实际本机时区不受影响,工具读取本机时区后的输出仍可包含真实值;排查见 [时区与客户端环境信息](../deploy/README.md#时区与客户端环境信息)