diff --git a/backend/apps/gateway/tests/bootstrap.rs b/backend/apps/gateway/tests/bootstrap.rs index cafbdcf9b..7ef9324eb 100644 --- a/backend/apps/gateway/tests/bootstrap.rs +++ b/backend/apps/gateway/tests/bootstrap.rs @@ -311,7 +311,7 @@ fn config_loader_should_reject_invalid_desktop_profile_fields() { } #[test] -fn config_loader_should_accept_default_and_custom_request_locations() { +fn config_loader_should_accept_empty_and_custom_request_locations() { let original = valid_config(); let omitted = original .lines() @@ -319,7 +319,14 @@ fn config_loader_should_accept_default_and_custom_request_locations() { .collect::>() .join("\n"); assert_ne!(original, omitted); - parse_config(&omitted).expect("location defaults when omitted"); + parse_config(&omitted).expect("location passthrough when omitted"); + let location_line = original + .lines() + .find(|line| line.trim_start().starts_with("location:")) + .expect("example location"); + for empty in [" location:", " location: null", " location: ~"] { + parse_config(&original.replace(location_line, empty)).expect("empty YAML location"); + } let custom = original.replace( "location: { country: 'US', region: 'Ohio', city: 'Piketon', timezone: 'America/New_York' }", "location: { country: 'NZ', region: 'Auckland', city: 'Auckland', timezone: 'Pacific/Auckland' }", diff --git a/backend/crates/providers/openai/src/config.rs b/backend/crates/providers/openai/src/config.rs index 2ff38144b..f84f48a9c 100644 --- a/backend/crates/providers/openai/src/config.rs +++ b/backend/crates/providers/openai/src/config.rs @@ -292,8 +292,9 @@ pub struct CodexWireProfileConfig { pub terminal: String, #[serde(default)] pub residency: Option, + /// 为空时保留客户端的地区、环境日期和时区。 #[serde(default)] - pub location: CodexRequestLocation, + pub location: Option, pub verified_at: DateTime, } @@ -309,7 +310,7 @@ impl Default for CodexWireProfileConfig { arch: "arm64".to_owned(), terminal: "unknown".to_owned(), residency: None, - location: CodexRequestLocation::default(), + location: None, // 制品核验于 2026-09-06T03:26:12.084Z;进程启动不构成重新核验。 verified_at: DateTime::UNIX_EPOCH + chrono::Duration::milliseconds(1_788_665_172_084), } @@ -336,29 +337,33 @@ impl CodexWireProfileConfig { ("openai.wire_profile.os_version", self.os_version.as_str()), ("openai.wire_profile.arch", self.arch.as_str()), ("openai.wire_profile.terminal", self.terminal.as_str()), - ( - "openai.wire_profile.location.region", - self.location.region.as_str(), - ), - ( - "openai.wire_profile.location.city", - self.location.city.as_str(), - ), ] { if value.trim().is_empty() { return Err(OpenAiConfigError::InvalidField(field)); } } - if self.location.country.len() != 2 - || !self - .location - .country - .bytes() - .all(|byte| byte.is_ascii_uppercase()) - { - return Err(OpenAiConfigError::InvalidField( - "openai.wire_profile.location.country", - )); + if let Some(location) = &self.location { + for (field, value) in [ + ( + "openai.wire_profile.location.region", + location.region.as_str(), + ), + ("openai.wire_profile.location.city", location.city.as_str()), + ] { + if value.trim().is_empty() { + return Err(OpenAiConfigError::InvalidField(field)); + } + } + if location.country.len() != 2 + || !location + .country + .bytes() + .all(|byte| byte.is_ascii_uppercase()) + { + return Err(OpenAiConfigError::InvalidField( + "openai.wire_profile.location.country", + )); + } } if semver::Version::parse(&self.codex_version).is_err() { return Err(OpenAiConfigError::InvalidField( diff --git a/backend/crates/providers/openai/src/provider/mod.rs b/backend/crates/providers/openai/src/provider/mod.rs index d7796fc41..1479a3d55 100644 --- a/backend/crates/providers/openai/src/provider/mod.rs +++ b/backend/crates/providers/openai/src/provider/mod.rs @@ -141,7 +141,7 @@ pub struct CodexProvider { quota: Arc, account_feedback: Arc, client: CodexBackendClient, - location: CodexRequestLocation, + location: Option, responses_url: Url, image_generations_url: Url, image_edits_url: Url, @@ -256,7 +256,8 @@ impl Provider for CodexProvider { ..Default::default() }; }; - let Ok(encoded) = encode_generate_request(request, "observability", &self.location) else { + let Ok(encoded) = encode_generate_request(request, "observability", self.location.as_ref()) + else { return ProviderRequestObservation::default(); }; let semantics = encoded.semantics(); @@ -358,7 +359,7 @@ impl Provider for CodexProvider { let previous_session = decode_openai_session_state(generate); let continuation_requested = generate.native_continuation_requested(); let mut upstream_request = - encode_generate_request(generate, upstream_model.as_str(), &self.location) + encode_generate_request(generate, upstream_model.as_str(), self.location.as_ref()) .map_err(map_request_error)?; if let Some(conversation_id) = previous_session .as_ref() diff --git a/backend/crates/providers/openai/src/transport/profile/mod.rs b/backend/crates/providers/openai/src/transport/profile/mod.rs index ff2aa6cf7..eddef345c 100644 --- a/backend/crates/providers/openai/src/transport/profile/mod.rs +++ b/backend/crates/providers/openai/src/transport/profile/mod.rs @@ -84,8 +84,8 @@ pub struct CodexWireProfile { pub terminal: String, /// 未配置时不发送 residency 头;不随制品版本更新而改变。 pub residency: Option, - /// 部署配置的固定地区,不随制品版本更新而改变。 - pub location: CodexRequestLocation, + /// 可选的地区覆盖;为空时透传,不随制品版本更新而改变。 + pub location: Option, /// 版本元组最后一次经制品核验的时间;不表示 TLS 传输已重新核验。 pub verified_at: DateTime, } diff --git a/backend/crates/providers/openai/src/transport/request.rs b/backend/crates/providers/openai/src/transport/request.rs index 7c51d5ebc..f93c65133 100644 --- a/backend/crates/providers/openai/src/transport/request.rs +++ b/backend/crates/providers/openai/src/transport/request.rs @@ -97,7 +97,7 @@ pub enum CodexRequestEncodeError { pub fn encode_generate_request( request: &GenerateRequest, upstream_model: &str, - location: &CodexRequestLocation, + location: Option<&CodexRequestLocation>, ) -> Result { let payload = request.protocol_payload(); if payload.protocol() != "openai" { @@ -116,13 +116,15 @@ pub fn encode_generate_request( fn adapt_codex_responses_body( body: &mut Map, upstream_model: &str, - location: &CodexRequestLocation, + location: Option<&CodexRequestLocation>, ) { body.insert("model".to_owned(), Value::String(upstream_model.to_owned())); for field in UNSUPPORTED_CODEX_RESPONSES_FIELDS { body.remove(*field); } - align_structured_location_fields(body, Utc::now(), location); + if let Some(location) = location { + align_structured_location_fields(body, Utc::now(), location); + } } fn align_structured_location_fields( diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index e5bf20c0b..4d78c4bc5 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -660,9 +660,8 @@ async fn openai_admin_provider_projects_cached_quota_models_and_canonical_export let Operation::Generate(request) = operation else { panic!("connection test must be a generate operation"); }; - let encoded = - provider_openai::encode_generate_request(&request, "gpt-5.4", &Default::default()) - .expect("official OpenAI request"); + let encoded = provider_openai::encode_generate_request(&request, "gpt-5.4", None) + .expect("official OpenAI request"); assert_eq!( encoded.body().get("model").and_then(Value::as_str), Some("gpt-5.4") diff --git a/backend/crates/providers/openai/tests/config.rs b/backend/crates/providers/openai/tests/config.rs index 9a297bab4..c4c8e60e5 100644 --- a/backend/crates/providers/openai/tests/config.rs +++ b/backend/crates/providers/openai/tests/config.rs @@ -9,12 +9,9 @@ use provider_openai::transport::profile::CodexRequestLocation; use serde_json::json; #[test] -fn openai_location_defaults_should_match_the_existing_request_profile() { +fn openai_location_should_default_to_passthrough() { let profile = OpenAiConfig::default().wire_profile_state().snapshot(); - assert_eq!(profile.location.country, "US"); - assert_eq!(profile.location.region, "Ohio"); - assert_eq!(profile.location.city, "Piketon"); - assert_eq!(profile.location.timezone.name(), "America/New_York"); + assert_eq!(profile.location, None); } #[test] @@ -24,11 +21,14 @@ fn openai_config_should_preserve_a_custom_location_in_the_runtime_profile() { })) .expect("location configuration"); let mut config = valid_config(); - config.wire_profile.location = location.clone(); + config.wire_profile.location = Some(location.clone()); config .resolve_and_validate(Path::new("/srv/gateway")) .expect("valid location"); - assert_eq!(config.wire_profile_state().snapshot().location, location); + assert_eq!( + config.wire_profile_state().snapshot().location, + Some(location) + ); } #[test] @@ -41,19 +41,17 @@ fn openai_config_should_reject_invalid_location_fields() { ("city", ""), ] { let mut config = valid_config(); + let location = config + .wire_profile + .location + .insert(CodexRequestLocation::default()); let (target, expected) = match field { "country" => ( - &mut config.wire_profile.location.country, + &mut location.country, "openai.wire_profile.location.country", ), - "region" => ( - &mut config.wire_profile.location.region, - "openai.wire_profile.location.region", - ), - _ => ( - &mut config.wire_profile.location.city, - "openai.wire_profile.location.city", - ), + "region" => (&mut location.region, "openai.wire_profile.location.region"), + _ => (&mut location.city, "openai.wire_profile.location.city"), }; *target = value.to_owned(); assert_eq!( diff --git a/backend/crates/providers/openai/tests/transport/headers.rs b/backend/crates/providers/openai/tests/transport/headers.rs index 0475e0b97..41e7dee6d 100644 --- a/backend/crates/providers/openai/tests/transport/headers.rs +++ b/backend/crates/providers/openai/tests/transport/headers.rs @@ -94,7 +94,7 @@ fn request_with_opaque_headers(use_websocket: bool) -> CodexResponsesRequest { let mut request = encode_generate_request( &GenerateRequest::from_protocol_payload(payload), "gpt-routed", - &Default::default(), + None, ) .expect("opaque request headers"); request.use_websocket = use_websocket; diff --git a/backend/crates/providers/openai/tests/transport/request.rs b/backend/crates/providers/openai/tests/transport/request.rs index 0b535adc1..1525c7a31 100644 --- a/backend/crates/providers/openai/tests/transport/request.rs +++ b/backend/crates/providers/openai/tests/transport/request.rs @@ -30,7 +30,8 @@ fn encoder_should_use_the_configured_location_for_tools_and_environment_dates() .with_timezone(&location.timezone) .format("%Y-%m-%d") .to_string(); - let encoded = encode_generate_request(&request(body), "gpt-test", &location).expect("encode"); + let encoded = + encode_generate_request(&request(body), "gpt-test", Some(&location)).expect("encode"); let after = Utc::now() .with_timezone(&location.timezone) .format("%Y-%m-%d") @@ -66,6 +67,31 @@ fn request(body: Map) -> GenerateRequest { ) } +#[test] +fn encoder_should_preserve_location_fields_when_no_override_is_configured() { + let body = json!({ + "input": [{ + "role": "user", + "content": [{"type": "input_text", "text": "2020-01-01Asia/Shanghai"}], + "internal_chat_message_metadata_passthrough": { + "content_item_kinds": ["environments.environment_context"], + "create_time": 1789293131.822 + } + }], + "tools": [ + {"type": "web_search", "user_location": {"type": "approximate", "country": "CN", "region": "Shanghai", "city": "Shanghai", "timezone": "Asia/Shanghai"}}, + {"type": "web_search_preview", "user_location": null}, + {"type": "web_search_preview_2025_03_11"}, + {"type": "function", "name": "keep_timezone", "parameters": {}} + ] + }).as_object().expect("request object").clone(); + let encoded = encode_generate_request(&request(body.clone()), "gpt-test", None) + .expect("encode without location override"); + assert_eq!(encoded.body().get("input"), body.get("input")); + assert_eq!(encoded.body().get("tools"), body.get("tools")); + assert_eq!(encoded.body().get("model"), Some(&json!("gpt-test"))); +} + #[test] fn encoder_should_preserve_openai_wire_fields_without_deriving_accountless_pool_identity() { let body = Map::from_iter([ @@ -87,8 +113,8 @@ fn encoder_should_preserve_openai_wire_fields_without_deriving_accountless_pool_ ]); let request = request(body); - let encoded = encode_generate_request(&request, "gpt-routed", &Default::default()) - .expect("encode wire payload"); + let encoded = + encode_generate_request(&request, "gpt-routed", None).expect("encode wire payload"); assert_eq!(encoded.body().get("model"), Some(&json!("gpt-routed"))); assert!(encoded.body().get("stream").is_none()); @@ -121,8 +147,8 @@ fn encoder_should_never_hash_prompt_content_into_an_accountless_pool_identity() }; for input in ["private stable prompt", "different private prompt"] { - let encoded = encode_generate_request(&request(input), "gpt-routed", &Default::default()) - .expect("encoded request"); + let encoded = + encode_generate_request(&request(input), "gpt-routed", None).expect("encoded request"); assert!(encoded.local_conversation_id.is_none()); } } @@ -143,8 +169,7 @@ fn encoder_should_patch_model_and_preserve_supported_generate_semantics() { ), ])); - let encoded = - encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode"); + let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode"); assert_eq!(encoded.body().get("model"), Some(&json!("gpt-test"))); assert!(encoded.body().get("stream").is_none()); assert_eq!(encoded.body().get("store"), Some(&json!(false))); @@ -164,8 +189,7 @@ fn encoder_should_remove_unsupported_fields_from_upstream_body() { ("temperature".to_owned(), json!(0.2)), ])); - let encoded = - encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode"); + let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode"); assert_eq!( Value::Object(encoded.body().clone()), @@ -228,8 +252,12 @@ fn encoder_should_align_structured_location_fields_without_rewriting_chat_text() .clone(); let before = Utc::now().with_timezone(&New_York); - let encoded = - encode_generate_request(&request(body), "gpt-test", &Default::default()).expect("encode"); + let encoded = encode_generate_request( + &request(body), + "gpt-test", + Some(&CodexRequestLocation::default()), + ) + .expect("encode"); let after = Utc::now().with_timezone(&New_York); let encoded = Value::Object(encoded.body().clone()); @@ -300,8 +328,7 @@ fn encoder_should_preserve_client_store_intent() { ("store".to_owned(), json!(true)), ])); - let encoded = - encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode"); + let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode"); assert_eq!(encoded.body().get("store"), Some(&json!(true))); } @@ -314,8 +341,7 @@ fn encoder_should_forward_the_client_prompt_cache_key() { ("prompt_cache_key".to_owned(), json!("cache-route")), ])); - let encoded = - encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode"); + let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode"); assert_eq!( encoded.body().get("prompt_cache_key"), @@ -331,8 +357,7 @@ fn encoder_should_restore_conversation_fallback_after_body_and_context_values() ("prompt_cache_key".to_owned(), json!("cache-conversation")), ])); - let encoded = - encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode"); + let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode"); assert_eq!( encoded.client_conversation_id.as_deref(), @@ -383,8 +408,7 @@ fn encoder_should_accept_metadata_session_and_thread_ids_but_ignore_legacy_conte ), ])); - let encoded = - encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode"); + let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode"); assert_eq!( ( @@ -430,8 +454,7 @@ fn encoder_should_extract_official_websocket_context_projection() { ), ])); - let encoded = - encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode"); + let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode"); assert_eq!(encoded.turn_state.as_deref(), Some("metadata-turn-state")); assert_eq!(encoded.client_turn_id.as_deref(), Some("metadata-turn-id")); @@ -472,8 +495,7 @@ fn header_context_should_win_over_body_topline_aliases() { ])); let request = GenerateRequest::from_protocol_payload(payload); - let encoded = - encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode"); + let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode"); assert_eq!(encoded.turn_state.as_deref(), Some("header-turn-state")); assert_eq!( @@ -499,8 +521,7 @@ fn encoder_should_preserve_downstream_websocket_connection_identity_outside_wire )])); let request = GenerateRequest::from_protocol_payload(payload); - let encoded = - encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode"); + let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode"); assert_eq!( encoded.downstream_websocket_connection_id.as_deref(), @@ -530,8 +551,7 @@ fn body_topline_alias_should_only_fill_an_absent_header_context() { )])); let request = GenerateRequest::from_protocol_payload(payload); - let encoded = - encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode"); + let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode"); assert_eq!(encoded.turn_state.as_deref(), Some("body-turn-state")); assert_eq!( @@ -556,8 +576,7 @@ fn encoder_should_project_explicit_websocket_transport_without_touching_body() { )])); let request = GenerateRequest::from_protocol_payload(payload); - let encoded = - encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode"); + let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode"); assert!(encoded.use_websocket); assert!(!encoded.force_http_sse); @@ -580,8 +599,7 @@ fn encoder_should_project_explicit_http_transport_without_touching_body() { )])); let request = GenerateRequest::from_protocol_payload(payload); - let encoded = - encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode"); + let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode"); assert!(!encoded.use_websocket); assert!(encoded.force_http_sse); @@ -607,8 +625,8 @@ fn encoder_should_preserve_opaque_provider_options_without_interpreting_them() { .expect("OpenAI payload"); let request = GenerateRequest::from_protocol_payload(payload); - let encoded = encode_generate_request(&request, "gpt-test", &Default::default()) - .expect("opaque options encode"); + let encoded = + encode_generate_request(&request, "gpt-test", None).expect("opaque options encode"); assert_eq!( encoded.body().get("provider_options"), @@ -645,8 +663,7 @@ fn encoder_should_project_lite_and_memgen_options_to_transport_state_only() { ])); let request = GenerateRequest::from_protocol_payload(payload); - let encoded = - encode_generate_request(&request, "gpt-test", &Default::default()).expect("encode"); + let encoded = encode_generate_request(&request, "gpt-test", None).expect("encode"); assert_eq!(encoded.responses_lite.as_deref(), Some("true")); assert_eq!(encoded.memgen_request.as_deref(), Some("true")); @@ -670,7 +687,7 @@ fn observability_semantics_should_reuse_codex_turn_metadata() { )])); let request = GenerateRequest::from_protocol_payload(payload); - let semantics = encode_generate_request(&request, "observability", &Default::default()) + let semantics = encode_generate_request(&request, "observability", None) .expect("observability request should encode") .semantics(); @@ -689,9 +706,8 @@ fn encoder_should_extract_subagent_kind_from_wire_or_turn_metadata() { json!({"x-openai-subagent": "review"}), ), ])); - let metadata_encoded = - encode_generate_request(&metadata_request, "gpt-test", &Default::default()) - .expect("encode metadata request"); + let metadata_encoded = encode_generate_request(&metadata_request, "gpt-test", None) + .expect("encode metadata request"); assert_eq!(metadata_encoded.subagent_kind().as_deref(), Some("review")); let turn_metadata_payload = ProtocolPayload::json_object( @@ -707,9 +723,8 @@ fn encoder_should_extract_subagent_kind_from_wire_or_turn_metadata() { Value::String(r#"{"subagent_kind":"worker"}"#.to_owned()), )])); let turn_metadata_request = GenerateRequest::from_protocol_payload(turn_metadata_payload); - let turn_metadata_encoded = - encode_generate_request(&turn_metadata_request, "gpt-test", &Default::default()) - .expect("encode turn metadata request"); + let turn_metadata_encoded = encode_generate_request(&turn_metadata_request, "gpt-test", None) + .expect("encode turn metadata request"); assert_eq!( turn_metadata_encoded.subagent_kind().as_deref(), Some("worker") @@ -742,7 +757,7 @@ fn observability_semantics_should_use_the_transparent_openai_payload() { .expect("OpenAI payload"); let request = GenerateRequest::from_protocol_payload(payload); - let semantics = encode_generate_request(&request, "observability", &Default::default()) + let semantics = encode_generate_request(&request, "observability", None) .expect("transparent OpenAI request should encode") .semantics(); diff --git a/deploy/README.md b/deploy/README.md index b9898a824..a059efeb5 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -48,7 +48,9 @@ Linux 上应用容器以 `10001:10001` 运行。上述命令将应用数据和 模板不重复列出这些默认项。运行后,Provider 检查官方版本并更新运行时请求画像, 不回写 `config.yaml`;检查失败时继续使用上一份有效画像。版本检查不等于重新核验 TLS。 -`openai.wire_profile.location` 集中配置请求地区,省略时默认为 `US / Ohio / Piketon / America/New_York`。 +`openai.wire_profile.location` 可选覆盖请求地区。省略、留空(`location:`)或设为 `null` 时, +透传客户端原有的 Web Search `user_location`、环境日期和时区;客户端未提供的字段也不会补写。 +模板显式填写 `US / Ohio / Piketon / America/New_York`,需要透传时清空或删除该配置项。 自定义时完整填写 `country`(两位大写国家代码)、`region`、`city` 和 `timezone`(IANA 时区);修改后重启生效。 它统一 Responses 的 Web Search 地区与带环境标记的日期、时区,不修改普通聊天内容或 epoch 时间戳, 也不替代 `residency` 约束或随官方版本检查变化。 diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index cbc55c26a..c22419aca 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -74,7 +74,8 @@ openai: terminal: 'unknown' # 部署选择的 US residency;省略或 null 时使用官方无地域约束的默认分支。 residency: us - # 请求地区画像;省略 location 时使用以下默认值。时区同时用于环境日期,不改变 epoch 时间戳。 + # 请求地区覆盖;省略或留空(location: / location: null)时透传客户端原值。 + # 配置时完整填写四个字段;时区同时用于环境日期,不改变 epoch 时间戳。 location: { country: 'US', region: 'Ohio', city: 'Piketon', timezone: 'America/New_York' } verified_at: '2026-09-06T11:26:12.084+08:00'