mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 00:06:08 +00:00
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:
@@ -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, ¤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<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
@@ -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
@@ -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#时区与客户端环境信息)
|
||||
|
||||
Reference in New Issue
Block a user