diff --git a/backend/Cargo.lock b/backend/Cargo.lock index ed25a85af..4ba735dc3 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2717,6 +2717,7 @@ dependencies = [ "base64 0.23.1", "bytes", "chrono", + "chrono-tz", "cookie", "flate2", "futures", diff --git a/backend/crates/providers/openai/Cargo.toml b/backend/crates/providers/openai/Cargo.toml index c78de3517..5c6284a28 100644 --- a/backend/crates/providers/openai/Cargo.toml +++ b/backend/crates/providers/openai/Cargo.toml @@ -13,6 +13,7 @@ async-trait = "0.1.89" base64 = "0.23.0" bytes = "1.11.1" chrono = { version = "0.4.45", features = ["clock", "serde"] } +chrono-tz = "0.10" cookie = "0.18.1" futures = "0.3.32" flate2 = "1.1.9" diff --git a/backend/crates/providers/openai/src/transport/request.rs b/backend/crates/providers/openai/src/transport/request.rs index 275b5bdfe..877748cc5 100644 --- a/backend/crates/providers/openai/src/transport/request.rs +++ b/backend/crates/providers/openai/src/transport/request.rs @@ -3,11 +3,14 @@ use std::io; use base64::{Engine as _, engine::general_purpose::STANDARD}; +use chrono::{DateTime, Utc}; +use chrono_tz::America::New_York; use gateway_core::operation::GenerateRequest; use gateway_protocol::openai::{ WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY, is_transport_managed_request_header, }; use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; +use roxmltree::Document; use serde::Serialize as _; use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; @@ -20,6 +23,12 @@ const PASSTHROUGH_HEADERS_CONTEXT_KEY: &str = "opaque_request_headers"; 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"; +// 请求中的地区画像以 PORTS 所在的 Piketon 为基准;epoch 时间戳仍保持绝对时间原值。 +const TARGET_COUNTRY: &str = "US"; +const TARGET_REGION: &str = "Ohio"; +const TARGET_CITY: &str = "Piketon"; +const TARGET_TIMEZONE: &str = "America/New_York"; const UNSUPPORTED_CODEX_RESPONSES_FIELDS: &[&str] = &["max_output_tokens", "temperature"]; const CROSS_ACCOUNT_IDENTITY_KEYS: &[&str] = &[ @@ -113,6 +122,128 @@ fn adapt_codex_responses_body(body: &mut Map, upstream_model: &st for field in UNSUPPORTED_CODEX_RESPONSES_FIELDS { body.remove(*field); } + align_structured_location_fields(body, Utc::now()); +} + +fn align_structured_location_fields(body: &mut Map, now: DateTime) { + let current_date = now.with_timezone(&New_York).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); + } + } + if let Some(tools) = body.get_mut("tools").and_then(Value::as_array_mut) { + for tool in tools { + align_web_search_location(tool); + } + } +} + +fn align_environment_context(item: &mut Value, current_date: &str) { + let Some(item) = item.as_object_mut() else { + return; + }; + if item.get("role").and_then(Value::as_str) != Some("user") { + return; + } + let content_kinds = item + .get("internal_chat_message_metadata_passthrough") + .and_then(Value::as_object) + .and_then(|metadata| metadata.get("content_item_kinds")) + .and_then(Value::as_array) + .map(|kinds| { + kinds + .iter() + .map(|kind| kind.as_str().map(str::to_owned)) + .collect::>() + }); + let Some(content) = item.get_mut("content").and_then(Value::as_array_mut) else { + 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) + { + continue; + } + let Some(part) = part.as_object_mut() else { + continue; + }; + if part.get("type").and_then(Value::as_str) != Some("input_text") { + continue; + } + let Some(Value::String(text)) = part.get_mut("text") else { + continue; + }; + if let Some(aligned) = aligned_environment_context(text, current_date) { + *text = aligned; + } + } +} + +fn aligned_environment_context(text: &str, current_date: &str) -> Option { + let trimmed = text.trim(); + if !trimmed.starts_with("") || !trimmed.ends_with("") + { + return None; + } + let document = Document::parse(text).ok()?; + let root = document.root_element(); + if !root.has_tag_name("environment_context") { + return None; + } + let mut replacements = root + .children() + .filter(|node| node.is_element()) + .filter_map(|node| { + let replacement = match node.tag_name().name() { + "current_date" => format!("{current_date}"), + "timezone" => format!("{TARGET_TIMEZONE}"), + _ => return None, + }; + Some((node.range(), replacement)) + }) + .collect::>(); + if replacements.is_empty() { + return None; + } + replacements.sort_unstable_by_key(|(range, _)| std::cmp::Reverse(range.start)); + let mut aligned = text.to_owned(); + for (range, replacement) in replacements { + aligned.replace_range(range, &replacement); + } + Some(aligned) +} + +fn align_web_search_location(tool: &mut Value) { + let Some(tool) = tool.as_object_mut() else { + return; + }; + let Some(tool_type) = tool.get("type").and_then(Value::as_str) else { + return; + }; + if tool_type != "web_search" && !tool_type.starts_with("web_search_") { + return; + } + tool.insert( + "user_location".to_owned(), + Value::Object(Map::from_iter([ + ("type".to_owned(), Value::String("approximate".to_owned())), + ( + "country".to_owned(), + Value::String(TARGET_COUNTRY.to_owned()), + ), + ("region".to_owned(), Value::String(TARGET_REGION.to_owned())), + ("city".to_owned(), Value::String(TARGET_CITY.to_owned())), + ( + "timezone".to_owned(), + Value::String(TARGET_TIMEZONE.to_owned()), + ), + ])), + ); } fn extract_request_context(request: &mut CodexResponsesRequest) { diff --git a/backend/crates/providers/openai/tests/transport/request.rs b/backend/crates/providers/openai/tests/transport/request.rs index df4880793..2419a52cc 100644 --- a/backend/crates/providers/openai/tests/transport/request.rs +++ b/backend/crates/providers/openai/tests/transport/request.rs @@ -1,3 +1,5 @@ +use chrono::{Datelike as _, Utc}; +use chrono_tz::America::New_York; use gateway_core::operation::{GenerateRequest, ProtocolPayload}; use serde_json::{Map, Value, json}; @@ -117,6 +119,120 @@ fn encoder_should_remove_unsupported_fields_from_upstream_body() { ); } +#[test] +fn encoder_should_align_structured_location_fields_without_rewriting_chat_text() { + let normal_chat = "\n 2026-09-13\n \ + Asia/Shanghai\n"; + let body = json!({ + "model": "client-model", + "input": [ + { + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "\n /Users/mike/Personal/workspace/PORTAL2\n zsh\n 2026-09-13\n Asia/Shanghai\n \n" + }], + "internal_chat_message_metadata_passthrough": { + "create_time": 1789293131.822, + "content_item_kinds": ["environments.environment_context"] + } + }, + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": normal_chat}], + "internal_chat_message_metadata_passthrough": { + "content_item_kinds": ["user.text"] + } + }, + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": normal_chat}] + } + ], + "tools": [ + {"type": "web_search"}, + { + "type": "function", + "name": "remember_timezone", + "description": "Keep Asia/Shanghai unchanged" + } + ], + "client_metadata": { + "x-codex-turn-metadata": "{\"turn_started_at_unix_ms\":1789293131822}", + "x-codex-ws-stream-request-start-ms": "1789293132000" + } + }) + .as_object() + .expect("request object") + .clone(); + let before = Utc::now().with_timezone(&New_York); + + let encoded = encode_generate_request(&request(body), "gpt-test").expect("encode"); + + let after = Utc::now().with_timezone(&New_York); + let encoded = Value::Object(encoded.body().clone()); + let environment = encoded + .pointer("/input/0/content/0/text") + .and_then(Value::as_str) + .expect("environment context text"); + let expected_dates = [ + format!( + "{:04}-{:02}-{:02}", + before.year(), + before.month(), + before.day() + ), + format!( + "{:04}-{:02}-{:02}", + after.year(), + after.month(), + after.day() + ), + ]; + assert!( + expected_dates + .iter() + .any(|date| environment.contains(&format!("{date}"))) + ); + assert!(environment.contains("America/New_York")); + assert_eq!( + encoded.pointer("/tools/0/user_location"), + Some(&json!({ + "type": "approximate", + "country": "US", + "region": "Ohio", + "city": "Piketon", + "timezone": "America/New_York" + })) + ); + assert_eq!( + encoded.pointer("/input/1/content/0/text"), + Some(&json!(normal_chat)) + ); + assert_eq!( + encoded.pointer("/input/2/content/0/text"), + Some(&json!(normal_chat)) + ); + assert_eq!( + encoded.pointer("/input/0/internal_chat_message_metadata_passthrough/create_time"), + Some(&json!(1789293131.822)) + ); + assert_eq!( + encoded.pointer("/tools/1/description"), + Some(&json!("Keep Asia/Shanghai unchanged")) + ); + assert_eq!( + encoded.pointer("/client_metadata"), + Some(&json!({ + "x-codex-turn-metadata": "{\"turn_started_at_unix_ms\":1789293131822}", + "x-codex-ws-stream-request-start-ms": "1789293132000" + })) + ); +} + #[test] fn encoder_should_preserve_client_store_intent() { let request = request(Map::from_iter([