fix(openai): align Desktop client time context overrides

Apply configured request locations to Desktop developer time contexts alongside environment contexts while preserving role and content-kind boundaries.

Cover date alignment, disabled overrides, proxy precedence, and HTTP/WebSocket requests. Update the location contract and troubleshooting documentation.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-09 20:53:59 +08:00
co-authored by Codex
parent f78b1ca487
commit 7a5521953f
5 changed files with 149 additions and 22 deletions
@@ -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<Utc>,
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, &current_date, location.timezone.name());
align_time_context(item, &current_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<String> {
fn aligned_time_context(
text: &str,
context_tag: &str,
current_date: &str,
timezone: &str,
) -> Option<String> {
let trimmed = text.trim();
if !trimmed.starts_with("<environment_context>") || !trimmed.ends_with("</environment_context>")
if !trimmed.starts_with(&format!("<{context_tag}>"))
|| !trimmed.ends_with(&format!("</{context_tag}>"))
{
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
@@ -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":"<environment_context><timezone>UTC</timezone></environment_context>"}], "internal_chat_message_metadata_passthrough":{"content_item_kinds":["environments.environment_context"], "create_time":1789293131.822}},
{"role":"user", "content":[{"type":"input_text", "text":"<environment_context><timezone>UTC</timezone></environment_context>"}], "internal_chat_message_metadata_passthrough":{"content_item_kinds":["user.text"]}},
{"role":"user", "content":[{"type":"input_text", "text":"<environment_context><timezone>UTC</timezone></environment_context>"}]}
{"role":"user", "content":[{"type":"input_text", "text":"<environment_context><timezone>UTC</timezone></environment_context>"}]},
{"role":"developer", "content":[{"type":"input_text", "text":"<codex_apps_client_time_context><timezone>UTC</timezone></codex_apps_client_time_context>"}], "internal_chat_message_metadata_passthrough":{"content_item_kinds":["additional_content.codex_apps_client_time_context"]}},
{"role":"developer", "content":[{"type":"input_text", "text":"<codex_apps_client_time_context><timezone>UTC</timezone></codex_apps_client_time_context>"}]}
], "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!("<environment_context><timezone>{expected}</timezone></environment_context>"))));
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!("<codex_apps_client_time_context><timezone>{expected}</timezone></codex_apps_client_time_context>"))));
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 = "<environment_context><cwd>/home/example/项目</cwd><timezone>Asia/Shanghai</timezone></environment_context>";
let desktop = "<codex_apps_client_time_context><timezone>Asia/Shanghai</timezone></codex_apps_client_time_context>";
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!(
@@ -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 = " <codex_apps_client_time_context><timezone>Asia/Shanghai</timezone>\n\
<current_date>2020-01-01</current_date>\n\
Use this client time context for user-facing dates, times, and schedules instead of the execution host's timezone and current date.\
</codex_apps_client_time_context>\n";
let environment = "<environment_context><current_date>2020-01-01</current_date><timezone>Asia/Shanghai</timezone></environment_context>";
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 = "<codex_apps_client_time_context><timezone>Asia/Shanghai</timezone><current_date>2020-01-01</current_date></codex_apps_client_time_context>";
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": "<codex_apps_client_time_context><timezone>Asia/Shanghai</codex_apps_client_time_context>"}]},
{"role": "developer", "content": [{"type": "input_text", "text": "<other_context><timezone>Asia/Shanghai</timezone></other_context>"}]},
{"role": "developer", "content": [{"type": "output_text", "text": desktop}]},
{"role": "developer", "content": [{"type": "input_text", "text": "<codex_apps_client_time_context><example><timezone>Asia/Shanghai</timezone><current_date>2020-01-01</current_date></example></codex_apps_client_time_context>"}]}
]);
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 =
+2 -2
View File
@@ -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 的保留范围见
+11 -5
View File
@@ -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"`,完整文本去除首尾空白后由
`<environment_context>` 与 `</environment_context>` 包围且为合法 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#时区与客户端环境信息)