From cc2dfab19a2bb04c00d1bc0e7371be94fbcb1abb Mon Sep 17 00:00:00 2001 From: Zyy Date: Tue, 15 Sep 2026 15:02:36 +0800 Subject: [PATCH] fix(openai): preserve client location when unset Accept omitted and null request locations without rewriting web search location or environment dates and timezones. Keep complete configured overrides validated and document the passthrough behavior. Validated 626 OpenAI tests, 38 gateway tests including architecture checks, Rustfmt, and strict workspace Clippy. Co-authored-by: Codex --- backend/apps/gateway/tests/bootstrap.rs | 11 +- backend/crates/providers/openai/src/config.rs | 45 ++++---- .../providers/openai/src/provider/mod.rs | 7 +- .../openai/src/transport/profile/mod.rs | 4 +- .../providers/openai/src/transport/request.rs | 8 +- .../crates/providers/openai/tests/admin.rs | 5 +- .../crates/providers/openai/tests/config.rs | 30 +++--- .../openai/tests/transport/headers.rs | 2 +- .../openai/tests/transport/request.rs | 101 ++++++++++-------- deploy/README.md | 4 +- deploy/config.example.yaml | 3 +- 11 files changed, 125 insertions(+), 95 deletions(-) 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'