From bd61e0391aaf8a15b305049ef1b9eb47096d69ef Mon Sep 17 00:00:00 2001 From: Clarence12138 <148194843+Clarence12138@users.noreply.github.com> Date: Thu, 17 Sep 2026 03:30:54 +0800 Subject: [PATCH] feat(settings): configure global and per-proxy request location (#123) * feat(proxies): configure per-proxy request location * feat(settings): manage global request timezone and location Move request location from YAML into runtime settings with an enable switch. Preserve client fields when disabled and keep per-proxy overrides first. Share location inputs and simplify proxy and settings controls. Co-authored-by: Codex --------- Co-authored-by: Zyy Co-authored-by: Codex --- backend/Cargo.lock | 1 + backend/apps/gateway/tests/bootstrap.rs | 42 ++--- .../crates/gateway-admin/src/model/proxies.rs | 4 + .../gateway-admin/src/model/settings.rs | 4 + .../gateway-admin/src/use_case/proxies.rs | 10 ++ .../gateway-admin/src/use_case/settings.rs | 3 +- .../gateway-admin/tests/use_case/accounts.rs | 2 + .../tests/use_case/freeze_recovery.rs | 2 + .../tests/use_case/observability.rs | 2 + .../gateway-admin/tests/use_case/settings.rs | 2 + .../crates/gateway-api/src/admin/proxies.rs | 16 ++ .../crates/gateway-api/src/admin/settings.rs | 15 ++ backend/crates/gateway-api/tests/admin/mod.rs | 4 + .../crates/gateway-api/tests/admin/proxies.rs | 69 ++++++++ .../gateway-api/tests/admin/settings.rs | 125 ++++++++++++++ backend/crates/gateway-core/Cargo.toml | 1 + .../gateway-core/src/account/location.rs | 61 +++++++ .../crates/gateway-core/src/account/mod.rs | 2 + .../crates/gateway-core/src/account/model.rs | 15 ++ .../gateway-core/src/engine/coordinator.rs | 1 + backend/crates/gateway-core/src/engine/mod.rs | 16 ++ .../crates/gateway-core/src/routing/mod.rs | 7 + .../gateway-core/src/routing/snapshot.rs | 41 +++++ .../gateway-core/tests/account/location.rs | 127 ++++++++++++++ .../crates/gateway-core/tests/account/mod.rs | 1 + .../gateway-core/tests/engine/coordinator.rs | 56 ++++++ .../gateway-core/tests/routing/snapshot.rs | 82 +++++++++ .../crates/gateway-store/src/admin_adapter.rs | 6 + .../src/postgres/account_groups.rs | 3 +- .../provider_accounts/admin_queries.rs | 3 +- .../postgres/provider_accounts/repository.rs | 4 +- .../src/postgres/provider_accounts/rows.rs | 30 ++-- .../gateway-store/src/postgres/proxies.rs | 57 ++++++- .../src/postgres/runtime_settings.rs | 40 ++++- .../gateway-store/src/postgres/snapshot.rs | 12 +- .../tests/postgres/provider_accounts/mod.rs | 2 + .../gateway-store/tests/postgres/proxies.rs | 161 +++++++++++++++++- .../tests/postgres/runtime_settings.rs | 84 +++++++++ backend/crates/providers/openai/src/config.rs | 32 +--- .../providers/openai/src/provider/mod.rs | 28 +-- .../openai/src/transport/profile/mod.rs | 24 +-- .../providers/openai/src/transport/request.rs | 2 +- .../crates/providers/openai/tests/admin.rs | 1 - .../crates/providers/openai/tests/config.rs | 68 -------- .../openai/tests/credential/catalog.rs | 1 - .../openai/tests/credential/contract.rs | 1 - .../tests/credential/profile_statistics.rs | 1 - .../openai/tests/credential/quota/mod.rs | 1 - .../openai/tests/provider/contract.rs | 97 +++++++++-- .../crates/providers/openai/tests/support.rs | 16 ++ .../openai/tests/transport/catalog.rs | 1 - .../providers/openai/tests/transport/mod.rs | 1 - .../openai/tests/transport/profile/mod.rs | 9 +- .../openai/tests/transport/reset_credits.rs | 1 - .../providers/openai/tests/transport/usage.rs | 1 - backend/migrations/.frozen-sha256 | 1 + .../0012_proxy_request_location.sql | 35 ++++ deploy/README.md | 14 +- deploy/config.example.yaml | 3 - docs/api.md | 27 ++- docs/architecture.md | 4 + frontend/src/api/index.ts | 2 + frontend/src/api/modules/proxies.ts | 6 +- frontend/src/api/modules/settings.ts | 3 + frontend/src/api/types/request-location.ts | 6 + .../src/components/RequestLocationFields.vue | 29 ++++ frontend/src/utils/request-location.ts | 16 ++ .../proxies/components/ProxyFormModal.vue | 14 +- frontend/src/views/proxies/index.vue | 34 +++- .../components/RequestLocationCard.vue | 24 +++ .../settings/composables/useSettingsForm.ts | 21 ++- frontend/src/views/settings/index.vue | 2 + 72 files changed, 1405 insertions(+), 234 deletions(-) create mode 100644 backend/crates/gateway-core/src/account/location.rs create mode 100644 backend/crates/gateway-core/tests/account/location.rs create mode 100644 backend/migrations/0012_proxy_request_location.sql create mode 100644 frontend/src/api/types/request-location.ts create mode 100644 frontend/src/components/RequestLocationFields.vue create mode 100644 frontend/src/utils/request-location.ts create mode 100644 frontend/src/views/settings/components/RequestLocationCard.vue diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 930450747..3d7f5ab86 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1510,6 +1510,7 @@ dependencies = [ "async-trait", "base64 0.23.1", "bytes", + "chrono-tz", "futures", "futures-timer", "semver", diff --git a/backend/apps/gateway/tests/bootstrap.rs b/backend/apps/gateway/tests/bootstrap.rs index 4731a0472..94632aaae 100644 --- a/backend/apps/gateway/tests/bootstrap.rs +++ b/backend/apps/gateway/tests/bootstrap.rs @@ -386,36 +386,22 @@ fn config_loader_should_reject_invalid_desktop_profile_fields() { } #[test] -fn config_loader_should_accept_empty_and_custom_request_locations() { +fn config_loader_should_reject_removed_request_location_setting() { let original = valid_config(); - let omitted = original - .lines() - .filter(|line| !line.trim_start().starts_with("location:")) - .collect::>() - .join("\n"); - assert_ne!(original, 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"); + parse_config(&original).expect("location belongs to runtime settings"); + for value in [ + "null", + "{}", + "{ country: 'US', region: 'Ohio', city: 'Piketon', timezone: 'America/New_York' }", + ] { + let legacy = original.replacen( + " residency: us", + &format!(" residency: us\n location: {value}"), + 1, + ); + assert_ne!(legacy, original); + assert_rejected(legacy); } - 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' }", - ); - assert_ne!(original, custom); - parse_config(&custom).expect("custom location from YAML"); -} - -#[test] -fn config_loader_should_reject_invalid_request_location_timezones() { - let original = valid_config(); - let invalid = original.replace("timezone: 'America/New_York'", "timezone: 'Not/A_Timezone'"); - assert_ne!(original, invalid); - assert_rejected(invalid); } fn assert_rejected(config: String) { diff --git a/backend/crates/gateway-admin/src/model/proxies.rs b/backend/crates/gateway-admin/src/model/proxies.rs index 591498e07..33adcf3aa 100644 --- a/backend/crates/gateway-admin/src/model/proxies.rs +++ b/backend/crates/gateway-admin/src/model/proxies.rs @@ -65,6 +65,7 @@ pub struct ProxyTestResult { #[derive(Debug, Clone)] pub struct ProxyRecord { + pub location: Option, pub id: String, pub name: String, pub proxy: OutboundProxy, @@ -86,12 +87,15 @@ pub struct ProxyPage { #[derive(Debug, Clone)] pub struct NewProxy { + pub location: Option, pub name: String, pub proxy: OutboundProxy, } #[derive(Debug, Clone)] pub struct UpdateProxy { + /// 外层为空保留配置,内层为空恢复全局继承。 + pub location: Option>, pub id: String, pub revision: Revision, pub name: String, diff --git a/backend/crates/gateway-admin/src/model/settings.rs b/backend/crates/gateway-admin/src/model/settings.rs index 031224dcc..52ecc1c26 100644 --- a/backend/crates/gateway-admin/src/model/settings.rs +++ b/backend/crates/gateway-admin/src/model/settings.rs @@ -18,6 +18,8 @@ pub use gateway_core::account::RotationStrategy; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RuntimeSettings { pub config_revision: Revision, + pub request_location_enabled: bool, + pub request_location: gateway_core::account::RequestLocation, pub model_mappings: ModelMappings, pub refresh_margin_seconds: u64, pub refresh_concurrency: u32, @@ -45,6 +47,8 @@ pub struct RuntimeSettings { /// 原子替换运行设置的命令。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReplaceRuntimeSettings { + pub request_location_enabled: bool, + pub request_location: gateway_core::account::RequestLocation, pub model_mappings: ModelMappings, pub refresh_margin_seconds: u64, pub refresh_concurrency: u32, diff --git a/backend/crates/gateway-admin/src/use_case/proxies.rs b/backend/crates/gateway-admin/src/use_case/proxies.rs index 691ff161c..04c8ef3ae 100644 --- a/backend/crates/gateway-admin/src/use_case/proxies.rs +++ b/backend/crates/gateway-admin/src/use_case/proxies.rs @@ -181,6 +181,11 @@ impl ProxiesService for DefaultProxiesService { context: &MutationContext, ) -> Result { command.name = validate_name(&command.name)?; + command.location = command + .location + .map(|location| location.normalized()) + .transpose() + .map_err(|_| AdminError::invalid("代理位置不合法"))?; let result = self .store .create(command, context) @@ -196,6 +201,11 @@ impl ProxiesService for DefaultProxiesService { context: &MutationContext, ) -> Result { command.name = validate_name(&command.name)?; + command.location = command + .location + .map(|location| location.map(|value| value.normalized()).transpose()) + .transpose() + .map_err(|_| AdminError::invalid("代理位置不合法"))?; let result = self .store .update(command, context) diff --git a/backend/crates/gateway-admin/src/use_case/settings.rs b/backend/crates/gateway-admin/src/use_case/settings.rs index 60cfb4280..af9dc5623 100644 --- a/backend/crates/gateway-admin/src/use_case/settings.rs +++ b/backend/crates/gateway-admin/src/use_case/settings.rs @@ -114,7 +114,8 @@ impl SettingsService for DefaultSettingsService { } fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError> { - let valid = command.refresh_margin_seconds > 0 + let valid = command.request_location.validate().is_ok() + && command.refresh_margin_seconds > 0 && command.refresh_concurrency > 0 && command.max_concurrent_per_account > 0 && command.max_waiting_per_key <= 1_000 diff --git a/backend/crates/gateway-admin/tests/use_case/accounts.rs b/backend/crates/gateway-admin/tests/use_case/accounts.rs index 74b9fa897..a8fc4e810 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts.rs @@ -973,6 +973,8 @@ struct StaticSettingsStore; impl SettingsStore for StaticSettingsStore { async fn load_runtime_settings(&self) -> AdminStoreResult { Ok(RuntimeSettings { + request_location_enabled: false, + request_location: Default::default(), config_revision: revision(1), model_mappings: Default::default(), refresh_margin_seconds: 300, diff --git a/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs b/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs index 9dd2ce6a8..5734fae0e 100644 --- a/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs +++ b/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs @@ -25,6 +25,8 @@ use super::accounts::{FakeAccountStore, FakeProviderAdmin, account_record, event fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> RuntimeSettings { RuntimeSettings { + request_location_enabled: false, + request_location: Default::default(), config_revision: revision(1), model_mappings: Default::default(), refresh_margin_seconds: 300, diff --git a/backend/crates/gateway-admin/tests/use_case/observability.rs b/backend/crates/gateway-admin/tests/use_case/observability.rs index 87dd2bd6f..bfe72b710 100644 --- a/backend/crates/gateway-admin/tests/use_case/observability.rs +++ b/backend/crates/gateway-admin/tests/use_case/observability.rs @@ -753,6 +753,8 @@ struct FixtureSettingsStore; impl SettingsStore for FixtureSettingsStore { async fn load_runtime_settings(&self) -> AdminStoreResult { Ok(RuntimeSettings { + request_location_enabled: false, + request_location: Default::default(), config_revision: Revision::new(1).expect("revision"), model_mappings: Default::default(), refresh_margin_seconds: 300, diff --git a/backend/crates/gateway-admin/tests/use_case/settings.rs b/backend/crates/gateway-admin/tests/use_case/settings.rs index 7f67c2abb..90819b979 100644 --- a/backend/crates/gateway-admin/tests/use_case/settings.rs +++ b/backend/crates/gateway-admin/tests/use_case/settings.rs @@ -61,6 +61,8 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() { request_id: "request-settings".to_owned(), }, ReplaceRuntimeSettings { + request_location_enabled: false, + request_location: Default::default(), model_mappings: Default::default(), refresh_margin_seconds: 0, refresh_concurrency: 1, diff --git a/backend/crates/gateway-api/src/admin/proxies.rs b/backend/crates/gateway-api/src/admin/proxies.rs index 7fbbb30c8..39df10273 100644 --- a/backend/crates/gateway-api/src/admin/proxies.rs +++ b/backend/crates/gateway-api/src/admin/proxies.rs @@ -48,6 +48,7 @@ struct RemoveAccountRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct CreateRequest { + location: Option, name: String, proxy_url: AccountProxyUpdate, } @@ -55,12 +56,23 @@ struct CreateRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct UpdateRequest { + #[serde(default, deserialize_with = "deserialize_location_update")] + location: Option>, id: String, revision: u64, name: String, proxy_url: Option, } +fn deserialize_location_update<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + Option::deserialize(deserializer).map(Some) +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct IdRequest { @@ -111,6 +123,7 @@ struct ProxyAccountView { #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ProxyView { + location: Option, id: String, name: String, endpoint: String, @@ -127,6 +140,7 @@ impl From for ProxyView { fn from(record: ProxyRecord) -> Self { let endpoint = record.proxy.endpoint(); Self { + location: record.location, id: record.id, name: record.name, has_authentication: record.proxy.expose_url() != endpoint, @@ -307,6 +321,7 @@ where .proxies() .create( NewProxy { + location: request.location, name: request.name, proxy, }, @@ -365,6 +380,7 @@ where .proxies() .update( UpdateProxy { + location: request.location, id: request.id, revision: revision(request.revision)?, name: request.name, diff --git a/backend/crates/gateway-api/src/admin/settings.rs b/backend/crates/gateway-api/src/admin/settings.rs index 7547a8774..9953a3638 100644 --- a/backend/crates/gateway-api/src/admin/settings.rs +++ b/backend/crates/gateway-api/src/admin/settings.rs @@ -34,6 +34,8 @@ pub type ModelMappings = BTreeMap; #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct RuntimeSettingsView { + pub request_location_enabled: bool, + pub request_location: gateway_core::account::RequestLocation, pub model_mappings: ModelMappings, pub refresh_margin_seconds: u64, pub refresh_concurrency: u64, @@ -62,6 +64,8 @@ pub struct RuntimeSettingsView { #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct UpdateRuntimeSettingsRequest { + pub request_location_enabled: bool, + pub request_location: gateway_core::account::RequestLocation, pub model_mappings: ModelMappings, pub refresh_margin_seconds: u64, pub refresh_concurrency: u64, @@ -88,6 +92,9 @@ pub struct UpdateRuntimeSettingsRequest { impl UpdateRuntimeSettingsRequest { /// 校验公共运行参数。 pub fn validate(&self) -> Result<(), WireValidationError> { + self.request_location + .validate() + .map_err(|_| WireValidationError::new("requestLocation"))?; validate_model_mappings(&self.model_mappings)?; for (value, field) in [ (self.max_waiting_per_key, "maxWaitingPerKey"), @@ -159,6 +166,11 @@ impl UpdateRuntimeSettingsRequest { fn into_command(self) -> Result { self.validate()?; Ok(ReplaceRuntimeSettings { + request_location_enabled: self.request_location_enabled, + request_location: self + .request_location + .normalized() + .map_err(|_| WireValidationError::new("requestLocation"))?, model_mappings: domain_model_mappings(self.model_mappings)?, refresh_margin_seconds: self.refresh_margin_seconds, refresh_concurrency: u32::try_from(self.refresh_concurrency) @@ -194,6 +206,8 @@ impl UpdateRuntimeSettingsRequest { impl From for RuntimeSettingsView { fn from(settings: RuntimeSettings) -> Self { Self { + request_location_enabled: settings.request_location_enabled, + request_location: settings.request_location, model_mappings: wire_model_mappings(settings.model_mappings), refresh_margin_seconds: settings.refresh_margin_seconds, refresh_concurrency: u64::from(settings.refresh_concurrency), @@ -534,6 +548,7 @@ fn map_wire_error(error: WireValidationError) -> AdminError { "settingsUsageRetentionOverflow" => "usageRetentionDays 不合法".to_owned(), "settingsOpsRetentionOverflow" => "opsEventRetentionDays 不合法".to_owned(), "settingsAuditRetentionOverflow" => "auditRetentionDays 不合法".to_owned(), + "requestLocation" => "请求位置不合法,请检查国家代码、地区和城市".to_owned(), "settingsFreezeThresholdOverflow" => "accountAutoFreezeThreshold 不合法".to_owned(), "accountAutoFreezeThreshold" => "账号自动冻结阈值应为 2~1000 的整数".to_owned(), "accountAutoFreezeWindowSeconds" => "账号自动冻结统计窗口应为 60~3600 秒".to_owned(), diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs index 0f2e9b0cf..58a96f08f 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -402,6 +402,8 @@ impl SettingsStore for MemorySettingsStore { ) -> AdminStoreResult { let mut settings = self.settings.lock().expect("settings"); let updated = RuntimeSettings { + request_location_enabled: command.request_location_enabled, + request_location: command.request_location, config_revision: next_revision(settings.config_revision), model_mappings: command.model_mappings, refresh_margin_seconds: command.refresh_margin_seconds, @@ -1268,6 +1270,8 @@ fn test_runtime_settings() -> RuntimeSettings { ), ]); RuntimeSettings { + request_location_enabled: false, + request_location: Default::default(), config_revision: Revision::new(7).expect("revision"), model_mappings: mappings, refresh_margin_seconds: 3_600, diff --git a/backend/crates/gateway-api/tests/admin/proxies.rs b/backend/crates/gateway-api/tests/admin/proxies.rs index a554d4c3d..0d391fa6b 100644 --- a/backend/crates/gateway-api/tests/admin/proxies.rs +++ b/backend/crates/gateway-api/tests/admin/proxies.rs @@ -19,6 +19,71 @@ use tower::ServiceExt as _; use super::{AdminTestFixture, AdminTestState}; +#[tokio::test] +async fn proxy_location_round_trips_preserves_omitted_and_clears_null() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let location = + json!({"country":"JP", "region":"Tokyo", "city":"Tokyo", "timezone":"Asia/Tokyo"}); + let (status, created) = request( + &fixture, + "/api/admin/proxies/create", + Some(json!({ + "name":"Tokyo", "proxyUrl":"http://proxy.example:8080", "location":location + })), + true, + ) + .await; + assert_eq!(status, StatusCode::CREATED); + assert_eq!(created["data"]["record"]["location"], location); + let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await; + assert_eq!(listed["data"]["items"][0]["location"], location); + for (revision, change, expected) in [ + (1, None, location.clone()), + (2, Some(Value::Null), Value::Null), + (3, Some(location.clone()), location.clone()), + ] { + let mut body = json!({"id":"proxy_test", "revision":revision, "name":"Renamed"}); + if let Some(change) = change { + body["location"] = change; + } + let (status, changed) = + request(&fixture, "/api/admin/proxies/update", Some(body), true).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(changed["data"]["record"]["location"], expected); + } +} + +#[tokio::test] +async fn proxy_location_rejects_invalid_and_incomplete_input() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + for (field, value) in [ + ("country", json!("jp")), + ("city", json!(" ")), + ("region", json!("Tokyo\n")), + ("city", json!("x".repeat(129))), + ("timezone", json!("Asia/Typo")), + ] { + let mut invalid = + json!({"country":"JP", "region":"Tokyo", "city":"Tokyo", "timezone":"Asia/Tokyo"}); + invalid[field] = value; + let (status, _) = request(&fixture, "/api/admin/proxies/create", Some(json!({"name":"Invalid", "proxyUrl":"http://proxy.example:8080", "location":invalid})), true).await; + assert!(status.is_client_error(), "invalid {field} accepted"); + } + let (status, _) = request(&fixture, "/api/admin/proxies/create", Some(json!({"name":"Incomplete", "proxyUrl":"http://proxy.example:8080", "location":{"country":"JP"}})), true).await; + assert!(status.is_client_error()); + let (status, created) = request( + &fixture, + "/api/admin/proxies/create", + Some(json!({"name":"Legacy", "proxyUrl":"http://proxy.example:8080"})), + true, + ) + .await; + assert_eq!(status, StatusCode::CREATED); + assert!(created["data"]["record"]["location"].is_null()); +} + #[derive(Default)] pub(super) struct MemoryProxies(Mutex>); @@ -98,6 +163,7 @@ impl ProxyStore for MemoryProxies { _: &MutationContext, ) -> AdminStoreResult { let record = ProxyRecord { + location: command.location, id: "proxy_test".to_owned(), name: command.name, proxy: command.proxy, @@ -122,6 +188,9 @@ impl ProxyStore for MemoryProxies { let mut stored = self.0.lock().unwrap(); let record = stored.as_mut().ok_or_else(missing)?; record.name = command.name; + if let Some(location) = command.location { + record.location = location; + } if let Some(proxy) = command.proxy { record.proxy = proxy; } diff --git a/backend/crates/gateway-api/tests/admin/settings.rs b/backend/crates/gateway-api/tests/admin/settings.rs index 139342e75..7337bd095 100644 --- a/backend/crates/gateway-api/tests/admin/settings.rs +++ b/backend/crates/gateway-api/tests/admin/settings.rs @@ -37,6 +37,8 @@ async fn response_json(response: axum::response::Response) -> Value { fn update_body() -> Value { json!({ + "requestLocationEnabled": false, + "requestLocation": {"country":"US", "region":"Ohio", "city":"Piketon", "timezone":"America/New_York"}, "modelMappings": { "gpt-5.4": "gpt-5.5", "grok-latest": "grok-4.5" @@ -99,6 +101,8 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() { use gateway_core::routing::{PublicModelId, UpstreamModelId}; let settings = RuntimeSettings { + request_location_enabled: false, + request_location: Default::default(), config_revision: Revision::new(7).expect("revision"), model_mappings: BTreeMap::from_iter([ ( @@ -140,6 +144,8 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() { assert_eq!( value, json!({ + "requestLocationEnabled": false, + "requestLocation": {"country":"US", "region":"Ohio", "city":"Piketon", "timezone":"America/New_York"}, "modelMappings": { "gpt-5.4": "gpt-5.5", "grok-latest": "grok-4.5" @@ -190,6 +196,8 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() { .cloned() .collect(); let settings = RuntimeSettings { + request_location_enabled: false, + request_location: Default::default(), config_revision: Revision::new(7).expect("revision"), model_mappings: request .model_mappings @@ -450,3 +458,120 @@ fn concurrency_queue_fields_validate_bounds_and_accept_disabled_queues() { .validate() .unwrap(); } + +#[tokio::test] +async fn request_location_should_normalize_toggle_and_round_trip() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let mut body = update_body(); + body["requestLocationEnabled"] = json!(true); + body["requestLocation"] = + json!({"country":"JP", "region":" Tokyo ", "city":" Tokyo ", "timezone":"Asia/Tokyo"}); + let expected = + json!({"country":"JP", "region":"Tokyo", "city":"Tokyo", "timezone":"Asia/Tokyo"}); + let response = app(fixture.state()) + .oneshot(request( + Method::POST, + "/api/admin/settings/update", + Some(body), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response_json(response).await["data"]["requestLocation"], + expected + ); + let response = app(fixture.state()) + .oneshot(request(Method::GET, "/api/admin/settings", None)) + .await + .unwrap(); + assert_eq!( + response_json(response).await["data"]["requestLocation"], + expected + ); + for enabled in [false, true] { + let mut body = update_body(); + body["requestLocationEnabled"] = json!(enabled); + body["requestLocation"] = expected.clone(); + let response = app(fixture.state()) + .oneshot(request( + Method::POST, + "/api/admin/settings/update", + Some(body), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let response = app(fixture.state()) + .oneshot(request(Method::GET, "/api/admin/settings", None)) + .await + .unwrap(); + let data = response_json(response).await["data"].clone(); + assert_eq!(data["requestLocationEnabled"], json!(enabled)); + assert_eq!(data["requestLocation"], expected); + } +} + +#[tokio::test] +async fn request_location_should_reject_invalid_or_missing_fields_without_replacing_settings() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let original = update_body()["requestLocation"].clone(); + // JSON 结构或时区解析失败由 Axum 返回 422,业务校验失败返回 400。 + let mut invalid = vec![ + (Value::Null, StatusCode::UNPROCESSABLE_ENTITY), + (json!({}), StatusCode::UNPROCESSABLE_ENTITY), + ( + json!({"timezone":"Asia/Tokyo"}), + StatusCode::UNPROCESSABLE_ENTITY, + ), + ]; + for (field, value, status) in [ + ("country", "USA", StatusCode::BAD_REQUEST), + ("country", "us", StatusCode::BAD_REQUEST), + ("city", "", StatusCode::BAD_REQUEST), + ("region", "\nTokyo", StatusCode::BAD_REQUEST), + ( + "timezone", + "Not/A_Timezone", + StatusCode::UNPROCESSABLE_ENTITY, + ), + ] { + let mut location = original.clone(); + location[field] = json!(value); + invalid.push((location, status)); + } + for (location, expected_status) in invalid { + let mut body = update_body(); + body["requestLocation"] = location; + let response = app(fixture.state()) + .oneshot(request( + Method::POST, + "/api/admin/settings/update", + Some(body), + )) + .await + .unwrap(); + assert_eq!(response.status(), expected_status); + } + let mut body = update_body(); + body.as_object_mut().unwrap().remove("requestLocation"); + let response = app(fixture.state()) + .oneshot(request( + Method::POST, + "/api/admin/settings/update", + Some(body), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + let response = app(fixture.state()) + .oneshot(request(Method::GET, "/api/admin/settings", None)) + .await + .unwrap(); + assert_eq!( + response_json(response).await["data"]["requestLocation"], + original + ); +} diff --git a/backend/crates/gateway-core/Cargo.toml b/backend/crates/gateway-core/Cargo.toml index ce72ee456..4647c075b 100644 --- a/backend/crates/gateway-core/Cargo.toml +++ b/backend/crates/gateway-core/Cargo.toml @@ -15,6 +15,7 @@ path = "tests/main.rs" async-trait = "0.1.89" base64 = "0.23.0" bytes = "1.11.1" +chrono-tz = { version = "0.10", features = ["serde"] } futures = "0.3.32" futures-timer = "3.0.3" serde = { version = "1.0.228", features = ["derive"] } diff --git a/backend/crates/gateway-core/src/account/location.rs b/backend/crates/gateway-core/src/account/location.rs new file mode 100644 index 000000000..9d76c01f5 --- /dev/null +++ b/backend/crates/gateway-core/src/account/location.rs @@ -0,0 +1,61 @@ +//! 请求位置只描述出口的业务配置,不改变代理连接身份或服务系统时区。 + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RequestLocation { + pub country: String, + pub region: String, + pub city: String, + pub timezone: chrono_tz::Tz, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum InvalidRequestLocation { + #[error("country must contain two uppercase ASCII letters")] + Country, + #[error("region must contain 1–128 characters without control characters")] + Region, + #[error("city must contain 1–128 characters without control characters")] + City, +} + +impl Default for RequestLocation { + fn default() -> Self { + Self { + country: "US".to_owned(), + region: "Ohio".to_owned(), + city: "Piketon".to_owned(), + timezone: chrono_tz::America::New_York, + } + } +} + +impl RequestLocation { + pub fn validate(&self) -> Result<(), InvalidRequestLocation> { + if self.country.len() != 2 || !self.country.bytes().all(|byte| byte.is_ascii_uppercase()) { + return Err(InvalidRequestLocation::Country); + } + for (value, error) in [ + (&self.region, InvalidRequestLocation::Region), + (&self.city, InvalidRequestLocation::City), + ] { + // 先检查原始输入,避免 trim 掩盖首尾的换行和其他控制字符。 + if value.chars().any(char::is_control) + || value.trim().is_empty() + || value.trim().chars().count() > 128 + { + return Err(error); + } + } + Ok(()) + } + + pub fn normalized(mut self) -> Result { + self.validate()?; + self.region = self.region.trim().to_owned(); + self.city = self.city.trim().to_owned(); + Ok(self) + } +} diff --git a/backend/crates/gateway-core/src/account/mod.rs b/backend/crates/gateway-core/src/account/mod.rs index 625e571ca..66c48781d 100644 --- a/backend/crates/gateway-core/src/account/mod.rs +++ b/backend/crates/gateway-core/src/account/mod.rs @@ -1,6 +1,8 @@ //! Provider 账号领域、持久化端口与同一 target 内的账号选择。 mod error; +mod location; +pub use location::{InvalidRequestLocation, RequestLocation}; mod model; mod model_access; pub use model_access::{ diff --git a/backend/crates/gateway-core/src/account/model.rs b/backend/crates/gateway-core/src/account/model.rs index aa4c60d96..7b04a0704 100644 --- a/backend/crates/gateway-core/src/account/model.rs +++ b/backend/crates/gateway-core/src/account/model.rs @@ -744,6 +744,7 @@ pub struct ProviderAccount { next_refresh_at: Option, has_refresh_token: bool, outbound_proxy: Option, + request_location: Option, } impl ProviderAccount { @@ -780,6 +781,7 @@ impl ProviderAccount { next_refresh_at: None, has_refresh_token: false, outbound_proxy: None, + request_location: None, } } @@ -809,6 +811,8 @@ impl ProviderAccount { #[must_use] pub fn with_outbound_proxy(mut self, proxy: Option) -> Self { + // 出口变化后不能沿用旧出口的位置;存储投影应在绑定出口后设置位置。 + self.request_location = None; self.outbound_proxy = proxy; self } @@ -818,6 +822,17 @@ impl ProviderAccount { self.outbound_proxy.as_ref() } + #[must_use] + pub fn with_request_location(mut self, location: Option) -> Self { + self.request_location = self.outbound_proxy.as_ref().and(location); + self + } + + #[must_use] + pub const fn request_location(&self) -> Option<&super::RequestLocation> { + self.request_location.as_ref() + } + #[must_use] pub fn with_account_facts( mut self, diff --git a/backend/crates/gateway-core/src/engine/coordinator.rs b/backend/crates/gateway-core/src/engine/coordinator.rs index c77168ee7..eaed69161 100644 --- a/backend/crates/gateway-core/src/engine/coordinator.rs +++ b/backend/crates/gateway-core/src/engine/coordinator.rs @@ -788,6 +788,7 @@ where })); let context = AttemptContext::new( RequestAttemptContext::new(self.request_id.clone(), self.client_api_key_ref.clone()) + .with_request_location(self.plan.request_location().cloned()) .with_concurrency_wait_budget(self.concurrency_wait_budget.clone()) .with_timing_started_at(self.observation.timing_started_at) .with_trace(self.trace.clone()), diff --git a/backend/crates/gateway-core/src/engine/mod.rs b/backend/crates/gateway-core/src/engine/mod.rs index c27de8a92..e8de22a1e 100644 --- a/backend/crates/gateway-core/src/engine/mod.rs +++ b/backend/crates/gateway-core/src/engine/mod.rs @@ -314,6 +314,7 @@ impl ContinuationAttempt { /// Provider 每次执行可见的 request-local context。 #[derive(Debug, Clone)] pub struct RequestAttemptContext { + request_location: Option, request_id: ModelRequestId, client_api_key_ref: ClientApiKeyId, timing_started_at: Instant, @@ -322,11 +323,21 @@ pub struct RequestAttemptContext { } impl RequestAttemptContext { + #[must_use] + pub fn with_request_location( + mut self, + location: Option, + ) -> Self { + self.request_location = location; + self + } + #[must_use] pub fn new(request_id: ModelRequestId, client_api_key_ref: ClientApiKeyId) -> Self { Self { request_id, client_api_key_ref, + request_location: None, timing_started_at: Instant::now(), trace: crate::diagnostics::TraceContext::default(), concurrency_wait_budget: crate::concurrency::ConcurrencyWaitBudget::default(), @@ -388,6 +399,11 @@ pub struct AttemptContext { } impl AttemptContext { + #[must_use] + pub const fn request_location(&self) -> Option<&crate::account::RequestLocation> { + self.request.request_location.as_ref() + } + /// 当前 attempt 的诊断关联;克隆后可传给后台 transport 任务。 #[must_use] pub fn trace(&self) -> crate::diagnostics::TraceContext { diff --git a/backend/crates/gateway-core/src/routing/mod.rs b/backend/crates/gateway-core/src/routing/mod.rs index 1ed4a9b78..161a18297 100644 --- a/backend/crates/gateway-core/src/routing/mod.rs +++ b/backend/crates/gateway-core/src/routing/mod.rs @@ -536,6 +536,7 @@ impl ProviderCandidate { /// 一次请求冻结的 Provider 尝试顺序。 #[derive(Debug, Clone)] pub struct RoutingPlan { + request_location: Option, config_revision: ConfigRevision, account_selection_policy: AccountSelectionPolicy, operation: OperationKind, @@ -545,6 +546,12 @@ pub struct RoutingPlan { } impl RoutingPlan { + /// 本次请求冻结的全局位置,重试时沿用同一份配置。 + #[must_use] + pub const fn request_location(&self) -> Option<&crate::account::RequestLocation> { + self.request_location.as_ref() + } + #[must_use] pub const fn config_revision(&self) -> ConfigRevision { self.config_revision diff --git a/backend/crates/gateway-core/src/routing/snapshot.rs b/backend/crates/gateway-core/src/routing/snapshot.rs index 0224ded16..38c3c843b 100644 --- a/backend/crates/gateway-core/src/routing/snapshot.rs +++ b/backend/crates/gateway-core/src/routing/snapshot.rs @@ -28,6 +28,8 @@ const MAXIMUM_CATALOG_STABILITY_ATTEMPTS: usize = 4; /// Store 在一个一致性读取中提供的调度设置事实。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct SnapshotSettingsFacts { + request_location_enabled: bool, + request_location: crate::account::RequestLocation, max_concurrent_per_account: u32, max_waiting_per_key: u32, max_waiting_per_account: u32, @@ -40,6 +42,17 @@ pub struct SnapshotSettingsFacts { } impl SnapshotSettingsFacts { + #[must_use] + pub fn with_request_location( + mut self, + location: crate::account::RequestLocation, + enabled: bool, + ) -> Self { + self.request_location_enabled = enabled; + self.request_location = location; + self + } + #[must_use] pub const fn with_concurrency_queues( mut self, @@ -63,6 +76,8 @@ impl SnapshotSettingsFacts { min_codex_cli_version: Option, ) -> Self { Self { + request_location_enabled: false, + request_location: crate::account::RequestLocation::default(), max_concurrent_per_account, max_waiting_per_key: 0, max_waiting_per_account: 0, @@ -452,6 +467,18 @@ async fn compile_runtime_snapshot( )); } + // 关闭自定义时保留持久化值,但不生成全局覆盖;请求继续使用客户端字段。 + let request_location = if facts.settings.request_location_enabled { + Some( + facts + .settings + .request_location + .normalized() + .map_err(|_| RuntimeSnapshotCompileError::InvalidData)?, + ) + } else { + None + }; RuntimeSnapshot::new( facts.config_revision, selection_policy, @@ -462,6 +489,7 @@ async fn compile_runtime_snapshot( .map_err(|_| RuntimeSnapshotCompileError::InvalidData) .map(|snapshot| { snapshot + .with_request_location(request_location) .with_client_queue_policy(client_queue_policy) .with_model_mappings(model_mappings) .with_account_directory(account_directory) @@ -473,6 +501,7 @@ async fn compile_runtime_snapshot( /// 数据面使用的不可变配置快照。 #[derive(Debug, Clone)] pub struct RuntimeSnapshot { + request_location: Option, revision: ConfigRevision, client_queue_policy: ConcurrencyQueuePolicy, account_selection_policy: AccountSelectionPolicy, @@ -489,6 +518,15 @@ pub struct RuntimeSnapshot { } impl RuntimeSnapshot { + #[must_use] + pub fn with_request_location( + mut self, + location: Option, + ) -> Self { + self.request_location = location; + self + } + #[must_use] pub const fn client_queue_policy(&self) -> ConcurrencyQueuePolicy { self.client_queue_policy @@ -568,6 +606,7 @@ impl RuntimeSnapshot { client_policy_map.retain(|_, policy| policy.enabled()); Ok(Self { + request_location: None, revision, account_selection_policy, client_queue_policy: ConcurrencyQueuePolicy::default(), @@ -876,6 +915,7 @@ impl RuntimeSnapshot { Ok(RoutingPlan { config_revision: self.revision, + request_location: self.request_location.clone(), account_selection_policy: self.account_selection_policy, operation: operation.kind(), max_attempts: NonZeroU32::new(super::MAX_REQUEST_ATTEMPTS) @@ -917,6 +957,7 @@ impl RuntimeSnapshot { }; Ok(RoutingPlan { config_revision: self.revision, + request_location: self.request_location.clone(), account_selection_policy: self.account_selection_policy, operation: operation.kind(), max_attempts: NonZeroU32::new(super::MAX_REQUEST_ATTEMPTS) diff --git a/backend/crates/gateway-core/tests/account/location.rs b/backend/crates/gateway-core/tests/account/location.rs new file mode 100644 index 000000000..505d62748 --- /dev/null +++ b/backend/crates/gateway-core/tests/account/location.rs @@ -0,0 +1,127 @@ +use gateway_core::account::{InvalidRequestLocation, RequestLocation}; +use serde_json::json; + +fn location() -> RequestLocation { + RequestLocation { + country: "JP".to_owned(), + region: "Tokyo".to_owned(), + city: "Tokyo".to_owned(), + timezone: chrono_tz::Asia::Tokyo, + } +} + +#[test] +fn changing_or_removing_proxy_drops_its_location() { + use gateway_core::account::OutboundProxy; + let direct = super::account("acct_location").with_request_location(Some(location())); + assert!(direct.request_location().is_none()); + let proxied = direct + .with_outbound_proxy(Some(OutboundProxy::parse("http://localhost:8080").unwrap())) + .with_request_location(Some(location())); + assert_eq!(proxied.request_location(), Some(&location())); + assert!( + proxied + .clone() + .with_outbound_proxy(None) + .request_location() + .is_none() + ); + assert!( + proxied + .with_outbound_proxy(Some(OutboundProxy::parse("http://localhost:8081").unwrap())) + .request_location() + .is_none() + ); +} + +#[test] +fn location_normalizes_names_and_preserves_timezone() { + let value = RequestLocation { + region: " Tokyo ".to_owned(), + city: " 東京 ".to_owned(), + ..location() + } + .normalized() + .expect("valid location"); + assert_eq!(value.region, "Tokyo"); + assert_eq!(value.city, "東京"); + assert_eq!(value.timezone.name(), "Asia/Tokyo"); + let encoded = serde_json::to_value(&value).expect("serialize"); + assert_eq!(encoded["timezone"], "Asia/Tokyo"); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + value + ); +} + +#[test] +fn location_rejects_invalid_country_codes() { + for country in ["", "J", "JPN", "jp", "J1", "日", " JP"] { + assert_eq!( + RequestLocation { + country: country.to_owned(), + ..location() + } + .validate(), + Err(InvalidRequestLocation::Country) + ); + } +} + +#[test] +fn location_rejects_empty_control_and_long_names() { + for name in [ + String::new(), + " ".to_owned(), + "Tokyo\n".to_owned(), + "\tTokyo".to_owned(), + "To\0kyo".to_owned(), + "東".repeat(129), + ] { + assert_eq!( + RequestLocation { + region: name.clone(), + ..location() + } + .normalized(), + Err(InvalidRequestLocation::Region) + ); + assert_eq!( + RequestLocation { + city: name, + ..location() + } + .normalized(), + Err(InvalidRequestLocation::City) + ); + } + assert!( + RequestLocation { + city: "東".repeat(128), + ..location() + } + .validate() + .is_ok() + ); +} + +#[test] +fn location_requires_complete_object_and_valid_iana_timezone() { + for timezone in ["Asia/Tokyo", "America/New_York", "Europe/London"] { + assert!( + serde_json::from_value::(json!({ + "country": "JP", "region": "Tokyo", "city": "Tokyo", "timezone": timezone + })) + .is_ok() + ); + } + for timezone in ["", "Not/A_Zone", "+09:00", "Asia/Typo"] { + assert!( + serde_json::from_value::(json!({ + "country": "JP", "region": "Tokyo", "city": "Tokyo", "timezone": timezone + })) + .is_err() + ); + } + assert!(serde_json::from_value::(json!({"country": "JP"})).is_err()); +} diff --git a/backend/crates/gateway-core/tests/account/mod.rs b/backend/crates/gateway-core/tests/account/mod.rs index 0c4bb707d..048671b0c 100644 --- a/backend/crates/gateway-core/tests/account/mod.rs +++ b/backend/crates/gateway-core/tests/account/mod.rs @@ -1,3 +1,4 @@ +mod location; mod model_access; mod proxy; mod selection; diff --git a/backend/crates/gateway-core/tests/engine/coordinator.rs b/backend/crates/gateway-core/tests/engine/coordinator.rs index 3fab0791f..4dc565edb 100644 --- a/backend/crates/gateway-core/tests/engine/coordinator.rs +++ b/backend/crates/gateway-core/tests/engine/coordinator.rs @@ -584,6 +584,14 @@ fn plan(operation: &Operation) -> RoutingPlan { fn plan_with_policy( operation: &Operation, account_selection_policy: AccountSelectionPolicy, +) -> RoutingPlan { + plan_with_location(operation, account_selection_policy, Default::default()) +} + +fn plan_with_location( + operation: &Operation, + account_selection_policy: AccountSelectionPolicy, + request_location: gateway_core::account::RequestLocation, ) -> RoutingPlan { let provider = ProviderKind::new("openai").expect("provider"); let public_model = PublicModelId::new("gpt-5").expect("public model"); @@ -626,6 +634,7 @@ fn plan_with_policy( Vec::new(), ) .expect("snapshot") + .with_request_location(Some(request_location)) .with_account_directory(Arc::clone(&directory)); snapshot .plan( @@ -4497,3 +4506,50 @@ fn deadline_before_first_event_records_no_provider_circuit_failure() { assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Failed); assert!(!state.finalizations[0].committed); } + +#[test] +fn global_request_location_should_reach_every_account_retry() { + let operation = generate_operation(); + let location = gateway_core::account::RequestLocation { + timezone: "Asia/Tokyo".parse().unwrap(), + ..Default::default() + }; + let route_plan = plan_with_location( + &operation, + plan(&operation).account_selection_policy(), + location.clone(), + ); + let (coordinator, _, provider) = coordinator(vec![ + Script::Stream { + account_id: "acct_first", + items: vec![Err(ProviderError::new( + ProviderErrorKind::RateLimited, + UpstreamSendState::Sent, + ) + .with_status(429) + .with_replay_safe())], + }, + Script::Stream { + account_id: "acct_second", + items: complete_stream(None), + }, + ]); + let mut session = block_on(coordinator.start( + model_request(&operation, SystemTime::now() + Duration::from_secs(30)), + operation, + route_plan, + None, + None, + CancellationToken::new(), + )) + .unwrap(); + block_on(session.collect_uncommitted()).unwrap(); + block_on(session.commit_downstream(Some(200))).unwrap(); + let contexts = provider.contexts.lock().unwrap(); + assert_eq!(contexts.len(), 2); + assert!( + contexts + .iter() + .all(|context| context.request_location() == Some(&location)) + ); +} diff --git a/backend/crates/gateway-core/tests/routing/snapshot.rs b/backend/crates/gateway-core/tests/routing/snapshot.rs index 7eca394e0..64e8a69f4 100644 --- a/backend/crates/gateway-core/tests/routing/snapshot.rs +++ b/backend/crates/gateway-core/tests/routing/snapshot.rs @@ -290,3 +290,85 @@ fn compiler(store: Arc) -> RuntimeSnapshotCompiler { fn revision(value: u64) -> ConfigRevision { ConfigRevision::new(value).expect("positive revision") } + +#[test] +fn global_request_location_should_be_frozen_when_snapshot_is_published() { + use gateway_core::account::{ProviderAccountId, RequestLocation}; + use gateway_core::runtime::RuntimeSnapshotHandle; + let make_snapshot = |version, timezone: &str, enabled| { + let location = RequestLocation { + timezone: timezone.parse().unwrap(), + ..RequestLocation::default() + }; + let facts = SnapshotFacts::new( + revision(version), + revision(version), + SnapshotSettingsFacts::new(3, 0, "smart", BTreeMap::new(), None, None) + .with_request_location(location, enabled), + Vec::new(), + Vec::new(), + vec![SnapshotProviderAccountFacts::new( + ProviderAccountId::new("acct_location").unwrap(), + "alpha", + )], + Vec::new(), + ); + block_on( + RuntimeSnapshotCompiler::new( + Arc::new(TestSnapshotStore::new(Ok(facts))), + Arc::new(TestCatalog::Unavailable), + ) + .compile(), + ) + .unwrap() + }; + let handle = RuntimeSnapshotHandle::new(make_snapshot(1, "Asia/Tokyo", true)); + let frozen = handle.acquire().unwrap(); + let plan = |snapshot: &gateway_core::routing::RuntimeSnapshot| { + snapshot + .plan( + &PublicModelId::new("public-model").unwrap(), + &super::operation(), + snapshot.all_account_scope(), + &gateway_core::routing::RoutingContext::default(), + ) + .unwrap() + }; + let old_plan = plan(&frozen); + handle.publish(make_snapshot(2, "America/New_York", true)); + assert_eq!( + old_plan.request_location().unwrap().timezone.name(), + "Asia/Tokyo" + ); + assert_eq!( + plan(&frozen).request_location().unwrap().timezone.name(), + "Asia/Tokyo" + ); + assert_eq!( + plan(&handle.acquire().unwrap()) + .request_location() + .unwrap() + .timezone + .name(), + "America/New_York" + ); + handle.publish(make_snapshot(3, "Asia/Tokyo", false)); + assert!( + plan(&handle.acquire().unwrap()) + .request_location() + .is_none() + ); + assert_eq!( + old_plan.request_location().unwrap().timezone.name(), + "Asia/Tokyo" + ); + handle.publish(make_snapshot(4, "Asia/Tokyo", true)); + assert_eq!( + plan(&handle.acquire().unwrap()) + .request_location() + .unwrap() + .timezone + .name(), + "Asia/Tokyo" + ); +} diff --git a/backend/crates/gateway-store/src/admin_adapter.rs b/backend/crates/gateway-store/src/admin_adapter.rs index 82a0f6de6..7e37d7222 100644 --- a/backend/crates/gateway-store/src/admin_adapter.rs +++ b/backend/crates/gateway-store/src/admin_adapter.rs @@ -43,6 +43,8 @@ impl SettingsStore for AdminSettingsStoreAdapter { refresh_margin_seconds: command.refresh_margin_seconds, refresh_concurrency: command.refresh_concurrency, max_concurrent_per_account: command.max_concurrent_per_account, + request_location_enabled: command.request_location_enabled, + request_location: command.request_location, request_interval_ms: command.request_interval_ms, max_waiting_per_key: command.max_waiting_per_key, max_waiting_per_account: command.max_waiting_per_account, @@ -69,6 +71,8 @@ impl SettingsStore for AdminSettingsStoreAdapter { "runtime_settings", "1", vec![ + "request_location_enabled".to_owned(), + "request_location_json".to_owned(), "model_mappings_json".to_owned(), "refresh_margin_seconds".to_owned(), "refresh_concurrency".to_owned(), @@ -176,6 +180,8 @@ pub(crate) fn admin_runtime_settings( .collect::>()?; Ok(AdminRuntimeSettings { config_revision: admin_revision(settings.config_revision)?, + request_location_enabled: settings.request_location_enabled, + request_location: settings.request_location, model_mappings, refresh_margin_seconds: settings.refresh_margin_seconds, refresh_concurrency: settings.refresh_concurrency, diff --git a/backend/crates/gateway-store/src/postgres/account_groups.rs b/backend/crates/gateway-store/src/postgres/account_groups.rs index e31a3df4f..05c306fd2 100644 --- a/backend/crates/gateway-store/src/postgres/account_groups.rs +++ b/backend/crates/gateway-store/src/postgres/account_groups.rs @@ -164,7 +164,7 @@ impl AccountGroupStore for PgAccountGroupRepository { .map(|group_id| group_id.as_str().to_owned()) .collect::>(); let rows = sqlx::query( - "select membership.account_group_id, + "select proxy.location_country, proxy.location_region, proxy.location_city, proxy.location_timezone, membership.account_group_id, account.id, account.provider_kind, account.name, account.notes, account.email, account.upstream_user_id, account.upstream_account_id, account.plan_type, account.authentication_kind, account.credential_revision, account.outbound_proxy_url, @@ -178,6 +178,7 @@ impl AccountGroupStore for PgAccountGroupRepository { settings.max_concurrent_per_account from account_group_accounts membership join provider_accounts account on account.id = membership.provider_account_id + left join outbound_proxies proxy on proxy.id = account.outbound_proxy_id cross join runtime_settings settings where settings.id = 1 and membership.account_group_id = any($1::text[]) diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_queries.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_queries.rs index 4c8590251..97e9cf88c 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_queries.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_queries.rs @@ -116,7 +116,7 @@ pub(crate) async fn load_admin_account_page( from runtime_settings where id = 1 ) - select a.outbound_proxy_url, a.id, a.provider_kind, a.name, a.notes, a.email, a.upstream_user_id, + select p.location_country, p.location_region, p.location_city, p.location_timezone, a.outbound_proxy_url, a.id, a.provider_kind, a.name, a.notes, a.email, a.upstream_user_id, a.upstream_account_id, a.plan_type, a.authentication_kind, a.credential_revision, a.has_refresh_token, a.access_token_expires_at, a.next_refresh_at, a.enabled, a.concurrency_limit, a.weight, a.model_access_json, @@ -143,6 +143,7 @@ pub(crate) async fn load_admin_account_page( ) ordered ) page on true left join provider_accounts a on a.id = page.id + left join outbound_proxies p on p.id = a.outbound_proxy_id order by page.page_position" ); // 动态片段只来自上面的封闭排序枚举与固定 usage predicate;所有请求值仍使用 bind。 diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs index 0ffe76696..7e5a33f84 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs @@ -109,13 +109,15 @@ impl ProviderAccountRepository for PgProviderAccountRepository { include_disabled: bool, ) -> StoreResult> { let rows = sqlx::query( - "select outbound_proxy_url, id, provider_kind, name, notes, email, upstream_user_id, + "select location_country, location_region, location_city, location_timezone, outbound_proxy_url, id, provider_kind, name, notes, email, upstream_user_id, upstream_account_id, plan_type, authentication_kind, credential_revision, has_refresh_token, access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, model_access_json, credential_state, credential_observed_at, quota_access_state, quota_evidence, quota_access_observed_at, quota_reset_at, quota_observed_at, last_error_reason, last_error_message, created_at, updated_at from provider_accounts + left join (select id as location_proxy_id, location_country, location_region, location_city, location_timezone from outbound_proxies) proxy_location + on outbound_proxy_id = location_proxy_id where ($1::text is null or provider_kind = $1) and ($2 or enabled) order by provider_kind, name, id", ) diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs index 3e0b6faee..645b2dad0 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs @@ -120,6 +120,7 @@ pub(crate) fn parse_error_reason(value: Option) -> StoreResult, pub outbound_proxy: Option, pub id: String, pub provider_kind: String, @@ -388,31 +389,38 @@ impl ProviderAccountStateUpdate { } } -pub(crate) const ACCOUNT_SELECT: &str = "select outbound_proxy_url, id, provider_kind, name, notes, email, upstream_user_id, - upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision, - has_refresh_token, access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, model_access_json, credential_state, - provider_quota_json, quota_access_state, quota_evidence, quota_access_observed_at, quota_reset_at, - last_error_reason, last_error_message, - credential_observed_at, quota_observed_at, created_at, updated_at - from provider_accounts where id = $1"; - -pub(crate) const ACCOUNT_SELECT_BY_IDS: &str = "select outbound_proxy_url, id, provider_kind, name, notes, email, upstream_user_id, +pub(crate) const ACCOUNT_SELECT: &str = "select location_country, location_region, location_city, location_timezone, outbound_proxy_url, id, provider_kind, name, notes, email, upstream_user_id, upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision, has_refresh_token, access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, model_access_json, credential_state, provider_quota_json, quota_access_state, quota_evidence, quota_access_observed_at, quota_reset_at, last_error_reason, last_error_message, credential_observed_at, quota_observed_at, created_at, updated_at from provider_accounts + left join (select id as location_proxy_id, location_country, location_region, location_city, location_timezone from outbound_proxies) proxy_location + on outbound_proxy_id = location_proxy_id + where id = $1"; + +pub(crate) const ACCOUNT_SELECT_BY_IDS: &str = "select location_country, location_region, location_city, location_timezone, outbound_proxy_url, id, provider_kind, name, notes, email, upstream_user_id, + upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision, + has_refresh_token, access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, model_access_json, credential_state, + provider_quota_json, quota_access_state, quota_evidence, quota_access_observed_at, quota_reset_at, + last_error_reason, last_error_message, + credential_observed_at, quota_observed_at, created_at, updated_at + from provider_accounts + left join (select id as location_proxy_id, location_country, location_region, location_city, location_timezone from outbound_proxies) proxy_location + on outbound_proxy_id = location_proxy_id where id = any($1::text[]) and provider_kind = $2 order by id"; -pub(crate) const REFRESH_CANDIDATES_SELECT: &str = "select outbound_proxy_url, id, provider_kind, name, notes, email, upstream_user_id, +pub(crate) const REFRESH_CANDIDATES_SELECT: &str = "select location_country, location_region, location_city, location_timezone, outbound_proxy_url, id, provider_kind, name, notes, email, upstream_user_id, upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision, has_refresh_token, access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, model_access_json, credential_state, provider_quota_json, quota_access_state, quota_evidence, quota_access_observed_at, quota_reset_at, last_error_reason, last_error_message, credential_observed_at, quota_observed_at, created_at, updated_at from provider_accounts + left join (select id as location_proxy_id, location_country, location_region, location_city, location_timezone from outbound_proxies) proxy_location + on outbound_proxy_id = location_proxy_id where provider_kind = $1 and enabled and has_refresh_token @@ -483,6 +491,7 @@ pub(crate) fn core_account_from_summary( .with_scheduling(summary.concurrency_limit, summary.weight) .with_model_access(summary.model_access) .with_outbound_proxy(summary.outbound_proxy) + .with_request_location(summary.request_location) .with_refresh_schedule( summary.has_refresh_token, summary.next_refresh_at.map(Into::into), @@ -541,6 +550,7 @@ pub(crate) fn account_summary_from_row( .and_then(AccountWeight::new) .ok_or_else(|| invalid("invalid weight"))?; Ok(ProviderAccountSummary { + request_location: super::super::proxies::location_from_row(&row)?, outbound_proxy: get::>(&row, "outbound_proxy_url")? .map(|url| { gateway_core::account::OutboundProxy::parse(&url) diff --git a/backend/crates/gateway-store/src/postgres/proxies.rs b/backend/crates/gateway-store/src/postgres/proxies.rs index de95320d0..f92ef58e9 100644 --- a/backend/crates/gateway-store/src/postgres/proxies.rs +++ b/backend/crates/gateway-store/src/postgres/proxies.rs @@ -98,6 +98,7 @@ fn record(row: PgRow) -> StoreResult { let ip: Option = row.try_get("last_test_ip").map_err(|_| invalid())?; let latency: Option = row.try_get("last_test_latency_ms").map_err(|_| invalid())?; Ok(ProxyRecord { + location: location_from_row(&row)?, id: row.try_get("id").map_err(|_| invalid())?, name: row.try_get("name").map_err(|_| invalid())?, proxy: OutboundProxy::parse( @@ -135,6 +136,46 @@ fn record(row: PgRow) -> StoreResult { }) } +pub(crate) fn location_from_row( + row: &PgRow, +) -> StoreResult> { + let country: Option = row.try_get("location_country").map_err(|_| invalid())?; + country + .map(|country| { + gateway_core::account::RequestLocation { + country, + region: row.try_get("location_region").map_err(|_| invalid())?, + city: row.try_get("location_city").map_err(|_| invalid())?, + timezone: row + .try_get::("location_timezone") + .map_err(|_| invalid())? + .parse() + .map_err(|_| invalid())?, + } + .normalized() + .map_err(|_| invalid()) + }) + .transpose() +} + +async fn save_location( + transaction: &mut Transaction<'_, Postgres>, + id: &str, + location: Option<&gateway_core::account::RequestLocation>, +) -> StoreResult<()> { + if let Some(location) = location { + location.validate().map_err(|_| invalid())?; + } + sqlx::query("update outbound_proxies set location_country = $2, location_region = $3, location_city = $4, location_timezone = $5 where id = $1") + .bind(id) + .bind(location.map(|value| value.country.as_str())) + .bind(location.map(|value| value.region.trim())) + .bind(location.map(|value| value.city.trim())) + .bind(location.map(|value| value.timezone.name())) + .execute(&mut **transaction).await.map_err(|_| unavailable())?; + Ok(()) +} + async fn lock_url( transaction: &mut Transaction<'_, Postgres>, proxy: &OutboundProxy, @@ -489,12 +530,15 @@ impl ProxyStore for PgProxyRepository { if !created { return Err(store_error(conflict(&id))); } + save_location(&mut transaction, &id, command.location.as_ref()) + .await + .map_err(store_error)?; audit( &mut transaction, context, "create", &id, - &["name", "proxy_url"], + &["name", "proxy_url", "location"], revision, ) .await @@ -555,6 +599,11 @@ impl ProxyStore for PgProxyRepository { if changed.rows_affected() != 1 { return Err(store_error(conflict(&command.id))); } + if let Some(location) = &command.location { + save_location(&mut transaction, &command.id, location.as_ref()) + .await + .map_err(store_error)?; + } sqlx::query("update provider_accounts a set outbound_proxy_url = p.proxy_url, updated_at = greatest(now(), a.updated_at) from outbound_proxies p where p.id = $1 and a.outbound_proxy_id = p.id and a.outbound_proxy_url is distinct from p.proxy_url") .bind(&command.id).execute(&mut *transaction).await.map_err(|_| store_error(unavailable()))?; audit( @@ -562,7 +611,11 @@ impl ProxyStore for PgProxyRepository { context, "update", &command.id, - &["name", "proxy_url"], + if command.location.is_some() { + &["name", "proxy_url", "location"] + } else { + &["name", "proxy_url"] + }, revision, ) .await diff --git a/backend/crates/gateway-store/src/postgres/runtime_settings.rs b/backend/crates/gateway-store/src/postgres/runtime_settings.rs index df0f9a40a..34adf6138 100644 --- a/backend/crates/gateway-store/src/postgres/runtime_settings.rs +++ b/backend/crates/gateway-store/src/postgres/runtime_settings.rs @@ -29,6 +29,8 @@ pub struct RuntimeSettings { pub max_waiting_per_account: u32, pub concurrency_wait_timeout_seconds: u32, pub rotation_strategy: String, + pub request_location_enabled: bool, + pub request_location: gateway_core::account::RequestLocation, pub model_mappings: BTreeMap, pub min_codex_desktop_version: Option, pub min_codex_cli_version: Option, @@ -62,6 +64,8 @@ impl fmt::Debug for RuntimeSettings { ) .field("request_interval_ms", &self.request_interval_ms) .field("rotation_strategy", &self.rotation_strategy) + .field("request_location_enabled", &self.request_location_enabled) + .field("request_location", &self.request_location) .field("model_mappings", &self.model_mappings) .field("min_codex_desktop_version", &self.min_codex_desktop_version) .field("min_codex_cli_version", &self.min_codex_cli_version) @@ -112,6 +116,8 @@ pub struct RuntimeSettingsUpdate { pub max_waiting_per_account: u32, pub concurrency_wait_timeout_seconds: u32, pub rotation_strategy: String, + pub request_location_enabled: bool, + pub request_location: gateway_core::account::RequestLocation, pub model_mappings: BTreeMap, pub min_codex_desktop_version: Option, pub min_codex_cli_version: Option, @@ -136,6 +142,8 @@ impl fmt::Debug for RuntimeSettingsUpdate { &self.admin_api_key.as_ref().map(|_| "[REDACTED]"), ) .field("rotation_strategy", &self.rotation_strategy) + .field("request_location_enabled", &self.request_location_enabled) + .field("request_location", &self.request_location) .field("model_mappings", &self.model_mappings) .finish_non_exhaustive() } @@ -143,7 +151,8 @@ impl fmt::Debug for RuntimeSettingsUpdate { impl RuntimeSettingsUpdate { pub fn validate(&self) -> StoreResult<()> { - if self.refresh_margin_seconds == 0 + if self.request_location.validate().is_err() + || self.refresh_margin_seconds == 0 || self.refresh_concurrency == 0 || self.max_concurrent_per_account == 0 || self.max_waiting_per_key > 1_000 @@ -216,7 +225,7 @@ impl RuntimeSettingsRepository for PgRuntimeSettingsRepository { pub(crate) async fn load_runtime_settings_from_pool(pool: &PgPool) -> StoreResult { let row = sqlx::query_as::<_, RuntimeSettingsRow>( - "select config_revision, admin_api_key, refresh_margin_seconds, + "select config_revision, admin_api_key, refresh_margin_seconds, request_location_json, request_location_enabled, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, model_mappings_json, usage_retention_days, ops_event_retention_days, audit_retention_days, min_codex_desktop_version, @@ -278,7 +287,7 @@ pub(crate) async fn load_runtime_settings_in_transaction( transaction: &mut Transaction<'_, Postgres>, ) -> StoreResult { let row = sqlx::query_as::<_, RuntimeSettingsRow>( - "select config_revision, admin_api_key, refresh_margin_seconds, + "select config_revision, admin_api_key, refresh_margin_seconds, request_location_json, request_location_enabled, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, model_mappings_json, usage_retention_days, ops_event_retention_days, audit_retention_days, min_codex_desktop_version, @@ -331,6 +340,8 @@ pub(crate) async fn update_runtime_settings_in_transaction( account_auto_freeze_probe_enabled = $20, account_auto_freeze_probe_model = $21, account_auto_freeze_adaptive_concurrency = $22, + request_location_json = $23, + request_location_enabled = $24, updated_at = now() where id = 1 returning config_revision", @@ -360,6 +371,14 @@ pub(crate) async fn update_runtime_settings_in_transaction( .bind(update.account_auto_freeze_probe_enabled) .bind(update.account_auto_freeze_probe_model.as_deref()) .bind(update.account_auto_freeze_adaptive_concurrency) + .bind(sqlx::types::Json( + update + .request_location + .clone() + .normalized() + .map_err(|_| invalid_location())?, + )) + .bind(update.request_location_enabled) .fetch_optional(&mut **transaction) .await .map_err(|_| postgres_unavailable("update runtime settings in transaction"))? @@ -416,6 +435,8 @@ struct RuntimeSettingsRow { max_concurrent_per_account: i64, request_interval_ms: i64, rotation_strategy: String, + request_location_enabled: bool, + request_location_json: sqlx::types::Json, model_mappings_json: sqlx::types::Json>, usage_retention_days: i64, ops_event_retention_days: i64, @@ -444,6 +465,12 @@ fn runtime_settings_from_row(row: RuntimeSettingsRow) -> StoreResult StoreResult { u32::try_from(value).map_err(|_| invalid_numeric()) } +fn invalid_location() -> StoreError { + StoreError::InvalidData { + entity: "runtime settings", + message: "request location is invalid".to_owned(), + } +} + fn invalid_numeric() -> StoreError { StoreError::InvalidData { entity: "runtime settings", diff --git a/backend/crates/gateway-store/src/postgres/snapshot.rs b/backend/crates/gateway-store/src/postgres/snapshot.rs index 410f70471..318d4c2b8 100644 --- a/backend/crates/gateway-store/src/postgres/snapshot.rs +++ b/backend/crates/gateway-store/src/postgres/snapshot.rs @@ -20,6 +20,8 @@ use super::ClientApiKeySnapshot; #[derive(Debug, Clone, PartialEq, Eq)] pub struct SnapshotRuntimeSettings { + pub request_location_enabled: bool, + pub request_location: gateway_core::account::RequestLocation, pub refresh_margin_seconds: u64, pub refresh_concurrency: u32, pub max_concurrent_per_account: u32, @@ -152,6 +154,10 @@ impl SnapshotStorePort for PgRuntimeSnapshotRepository { data.settings.min_codex_desktop_version, data.settings.min_codex_cli_version, ) + .with_request_location( + data.settings.request_location, + data.settings.request_location_enabled, + ) .with_concurrency_queues( data.settings.max_waiting_per_key, data.settings.max_waiting_per_account, @@ -241,12 +247,14 @@ async fn load_settings( Option, Option, i64, i64, i64, + sqlx::types::Json, + bool, ), >( "select config_revision, refresh_margin_seconds, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, model_mappings_json, min_codex_desktop_version, - min_codex_cli_version, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds + min_codex_cli_version, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, request_location_json, request_location_enabled from runtime_settings where id = 1", ) .fetch_optional(&mut **transaction) @@ -259,6 +267,8 @@ async fn load_settings( Ok(( revision_from_i64(row.0)?, SnapshotRuntimeSettings { + request_location_enabled: row.13, + request_location: row.12.0, refresh_margin_seconds: to_u64(row.1)?, refresh_concurrency: to_u32(row.2)?, max_concurrent_per_account: to_u32(row.3)?, diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs index bfb3f12f0..cdeb91c69 100644 --- a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs @@ -1993,6 +1993,7 @@ async fn authorization_import_rejects_a_saved_proxy_changed_during_oauth() { let saved = proxies .create( NewProxy { + location: None, name: "OAuth".to_owned(), proxy: original.clone(), }, @@ -2015,6 +2016,7 @@ async fn authorization_import_rejects_a_saved_proxy_changed_during_oauth() { let edited = proxies .update( UpdateProxy { + location: None, id: saved.id.clone(), revision: saved.revision, name: saved.name, diff --git a/backend/crates/gateway-store/tests/postgres/proxies.rs b/backend/crates/gateway-store/tests/postgres/proxies.rs index 34868bd18..852c212aa 100644 --- a/backend/crates/gateway-store/tests/postgres/proxies.rs +++ b/backend/crates/gateway-store/tests/postgres/proxies.rs @@ -16,6 +16,139 @@ use gateway_store::postgres::{ use super::{TestDatabase, admin_account_store, provider_accounts::account}; +#[tokio::test] +async fn proxy_location_is_shared_preserved_cleared_and_removed_with_binding() { + use gateway_core::account::{ProviderAccountStore, RequestLocation}; + let Some(database) = TestDatabase::create("proxy_location").await else { + return; + }; + let proxies = PgProxyRepository::new(database.pool.clone()); + let accounts = PgProviderAccountRepository::new(database.pool.clone()); + let location: RequestLocation = serde_json::from_value(serde_json::json!({"country":"JP", "region":"Tokyo", "city":"Tokyo", "timezone":"Asia/Tokyo"})).unwrap(); + let mut saved = proxies + .create( + NewProxy { + name: "Tokyo".to_owned(), + proxy: OutboundProxy::parse("http://127.0.0.1:18080").unwrap(), + location: Some(location.clone()), + }, + &context(), + ) + .await + .unwrap() + .record; + assert_eq!(saved.location.as_ref(), Some(&location)); + proxies + .record_test(&saved.id, saved.revision, success(), &context()) + .await + .unwrap(); + for id in ["acct_location_a", "acct_location_b"] { + let mut input = account(id, id); + input.outbound_proxy = Some(saved.proxy.clone()); + accounts.insert_provider_account(input).await.unwrap(); + let projected = accounts + .get_account(&ProviderAccountId::new(id).unwrap()) + .await + .unwrap() + .unwrap(); + assert_eq!(projected.request_location(), Some(&location)); + } + let directory = accounts.list_accounts().await.unwrap(); + assert_eq!(directory.len(), 2); + assert!( + directory + .iter() + .all(|account| account.request_location() == Some(&location)) + ); + let original_revision = directory[0].revision(); + saved = proxies + .update( + UpdateProxy { + id: saved.id.clone(), + revision: saved.revision, + name: "Renamed".to_owned(), + proxy: None, + location: None, + }, + &context(), + ) + .await + .unwrap() + .record; + assert_eq!(saved.location.as_ref(), Some(&location)); + assert!(saved.last_test.as_ref().unwrap().success); + let stale_revision = saved.revision; + saved = proxies + .update( + UpdateProxy { + id: saved.id.clone(), + revision: saved.revision, + name: saved.name.clone(), + proxy: None, + location: Some(None), + }, + &context(), + ) + .await + .unwrap() + .record; + assert!(saved.location.is_none()); + assert!(saved.last_test.as_ref().unwrap().success); + assert!( + accounts + .list_accounts() + .await + .unwrap() + .iter() + .all(|account| account.request_location().is_none() + && account.revision() == original_revision) + ); + assert!( + proxies + .update( + UpdateProxy { + id: saved.id.clone(), + revision: stale_revision, + name: saved.name.clone(), + proxy: None, + location: Some(Some(location.clone())) + }, + &context() + ) + .await + .is_err() + ); + assert!(proxies.get(&saved.id).await.unwrap().location.is_none()); + proxies + .update( + UpdateProxy { + id: saved.id.clone(), + revision: saved.revision, + name: saved.name.clone(), + proxy: None, + location: Some(Some(location.clone())), + }, + &context(), + ) + .await + .unwrap(); + let id = ProviderAccountId::new("acct_location_a").unwrap(); + proxies + .remove_account(&saved.id, &id, &context()) + .await + .unwrap(); + let removed = accounts.get_account(&id).await.unwrap().unwrap(); + assert!(removed.outbound_proxy().is_none()); + assert!(removed.request_location().is_none()); + let other = accounts + .get_account(&ProviderAccountId::new("acct_location_b").unwrap()) + .await + .unwrap() + .unwrap(); + assert_eq!(other.request_location(), Some(&location)); + database.close().await; +} + fn context() -> MutationContext { MutationContext { actor: MutationActor::System, @@ -55,6 +188,7 @@ async fn proxy_account_removal_preserves_settings_and_rejects_changed_bindings() let saved = store .create( NewProxy { + location: None, name: "解绑测试".to_owned(), proxy: OutboundProxy::parse("http://user:secret@127.0.0.1:17890").unwrap(), }, @@ -199,6 +333,7 @@ async fn proxy_accounts_paginate_thousands_of_accounts_and_search_without_loadin let saved = store .create( NewProxy { + location: None, name: "分页测试".to_owned(), proxy: OutboundProxy::parse("http://127.0.0.1:17890").unwrap(), }, @@ -210,6 +345,7 @@ async fn proxy_accounts_paginate_thousands_of_accounts_and_search_without_loadin let empty = store .create( NewProxy { + location: None, name: "无关联账号".to_owned(), proxy: OutboundProxy::parse("http://127.0.0.1:17891").unwrap(), }, @@ -351,6 +487,7 @@ async fn import_reservation_blocks_proxy_mutations_until_rotated_credentials_are let saved = store .create( NewProxy { + location: None, name: "导入出口".to_owned(), proxy: OutboundProxy::parse("http://127.0.0.1:8080").unwrap(), }, @@ -366,6 +503,7 @@ async fn import_reservation_blocks_proxy_mutations_until_rotated_credentials_are .unwrap(); let reservation = store.reserve_import(&saved.id).await.unwrap(); let replacement = UpdateProxy { + location: None, id: saved.id.clone(), revision: saved.revision, name: saved.name, @@ -463,6 +601,7 @@ async fn managed_proxies_persist_bind_update_all_accounts_and_protect_stale_test let created = store .create( NewProxy { + location: None, name: "Office".to_owned(), proxy: old_proxy.clone(), }, @@ -476,6 +615,7 @@ async fn managed_proxies_persist_bind_update_all_accounts_and_protect_stale_test store .create( NewProxy { + location: None, name: "Duplicate".to_owned(), proxy: old_proxy.clone() }, @@ -535,6 +675,7 @@ async fn managed_proxies_persist_bind_update_all_accounts_and_protect_stale_test UpdateProxy { id: created.id.clone(), revision: created.revision, + location: None, name: "Renamed".to_owned(), proxy: None, }, @@ -552,6 +693,7 @@ async fn managed_proxies_persist_bind_update_all_accounts_and_protect_stale_test UpdateProxy { id: created.id.clone(), revision: renamed.revision, + location: None, name: renamed.name, proxy: Some(new_proxy.clone()), }, @@ -680,7 +822,9 @@ async fn migration_backfills_shared_proxies_without_changing_credentials() { .await .unwrap(); } - sqlx::raw_sql("alter table provider_accounts drop column outbound_proxy_id; drop table outbound_proxies; + // 回退到迁移前的结构,随后完整重放同一份位置迁移。 + sqlx::raw_sql("alter table runtime_settings drop column request_location_json, drop column request_location_enabled; + alter table provider_accounts drop column outbound_proxy_id; drop table outbound_proxies; update provider_accounts set outbound_proxy_url = 'http://user:secret@127.0.0.1:8080/' where id <> 'acct_direct';") .execute(&database.pool).await.unwrap(); sqlx::raw_sql(include_str!( @@ -689,6 +833,21 @@ async fn migration_backfills_shared_proxies_without_changing_credentials() { .execute(&database.pool) .await .unwrap(); + sqlx::raw_sql(include_str!( + "../../../../migrations/0012_proxy_request_location.sql" + )) + .execute(&database.pool) + .await + .unwrap(); + let inherited = gateway_core::account::ProviderAccountStore::list_accounts(&accounts) + .await + .unwrap(); + assert_eq!(inherited.len(), 3); + assert!( + inherited + .iter() + .all(|account| account.request_location().is_none()) + ); assert_eq!( sqlx::query_scalar::<_, i64>("select count(*) from outbound_proxies") .fetch_one(&database.pool) diff --git a/backend/crates/gateway-store/tests/postgres/runtime_settings.rs b/backend/crates/gateway-store/tests/postgres/runtime_settings.rs index 988c163cd..0daeb0e78 100644 --- a/backend/crates/gateway-store/tests/postgres/runtime_settings.rs +++ b/backend/crates/gateway-store/tests/postgres/runtime_settings.rs @@ -9,6 +9,8 @@ use super::TestDatabase; fn settings_with_margin(refresh_margin_seconds: u64) -> RuntimeSettingsUpdate { RuntimeSettingsUpdate { + request_location_enabled: false, + request_location: Default::default(), admin_api_key: None, refresh_margin_seconds, refresh_concurrency: 2, @@ -270,6 +272,88 @@ async fn concurrency_queue_settings_round_trip_into_the_runtime_snapshot() { database.close().await; } +#[tokio::test] +async fn request_location_defaults_and_updates_reach_the_runtime_snapshot() { + use gateway_core::account::RequestLocation; + use gateway_store::postgres::{PgRuntimeSnapshotRepository, RuntimeSnapshotRepository}; + let Some(database) = TestDatabase::create("global_request_location").await else { + return; + }; + let repository = PgRuntimeSettingsRepository::new(database.pool.clone()); + let before = repository.load_runtime_settings().await.unwrap(); + assert_eq!(before.request_location, RequestLocation::default()); + assert!(!before.request_location_enabled); + let mut update = settings_with_margin(3600); + update.request_location = serde_json::from_value(serde_json::json!({"country":"JP", "region":" Tokyo ", "city":" Tokyo ", "timezone":"Asia/Tokyo"})).unwrap(); + update.request_location_enabled = true; + update.account_auto_freeze_threshold = 17; + update.account_auto_freeze_window_seconds = 900; + update.account_auto_freeze_duration_seconds = 3_600; + update.account_auto_freeze_probe_model = Some("gpt-5.5".to_owned()); + update.account_auto_freeze_adaptive_concurrency = false; + let expected = update.request_location.clone().normalized().unwrap(); + let mut disabled = update.clone(); + disabled.request_location = expected.clone(); + disabled.request_location_enabled = false; + repository.update_runtime_settings(update).await.unwrap(); + let settings = repository.load_runtime_settings().await.unwrap(); + let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone()) + .load_runtime_snapshot() + .await + .unwrap(); + assert_eq!(settings.request_location, expected); + assert_eq!(snapshot.settings.request_location, expected); + assert!(settings.request_location_enabled); + assert!(snapshot.settings.request_location_enabled); + assert!(snapshot.config_revision > before.config_revision); + repository.update_runtime_settings(disabled).await.unwrap(); + let disabled_settings = repository.load_runtime_settings().await.unwrap(); + assert!(!disabled_settings.request_location_enabled); + assert_eq!(disabled_settings.request_location, expected); + let disabled_snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone()) + .load_runtime_snapshot() + .await + .unwrap(); + assert!(!disabled_snapshot.settings.request_location_enabled); + assert_eq!(disabled_snapshot.settings.request_location, expected); + // 位置开关与自动冻结共用设置写入,切换位置不能覆盖冻结参数。 + for saved in [&settings, &disabled_settings] { + assert!(saved.account_auto_freeze_enabled); + assert_eq!(saved.account_auto_freeze_threshold, 17); + assert_eq!(saved.account_auto_freeze_window_seconds, 900); + assert_eq!(saved.account_auto_freeze_duration_seconds, 3_600); + assert!(saved.account_auto_freeze_probe_enabled); + assert_eq!( + saved.account_auto_freeze_probe_model.as_deref(), + Some("gpt-5.5") + ); + assert!(!saved.account_auto_freeze_adaptive_concurrency); + } + assert!(disabled_snapshot.config_revision > snapshot.config_revision); + for invalid in [ + serde_json::json!(null), + serde_json::json!({}), + serde_json::json!({"country":"US", "region":"Ohio", "city":null, "timezone":"America/New_York"}), + ] { + assert!( + sqlx::query("update runtime_settings set request_location_json = $1 where id = 1") + .bind(sqlx::types::Json(invalid)) + .execute(&database.pool) + .await + .is_err() + ); + } + assert_eq!( + repository + .load_runtime_settings() + .await + .unwrap() + .request_location, + expected + ); + database.close().await; +} + #[tokio::test] async fn auto_freeze_defaults_off_and_migration_preserves_explicit_opt_in() { let Some(database) = TestDatabase::create("freeze_opt_in").await else { diff --git a/backend/crates/providers/openai/src/config.rs b/backend/crates/providers/openai/src/config.rs index 9a62a4a2a..9fef1120a 100644 --- a/backend/crates/providers/openai/src/config.rs +++ b/backend/crates/providers/openai/src/config.rs @@ -8,9 +8,7 @@ use serde::Deserialize; use url::Url; use crate::credential::CodexQuotaRefreshPolicy; -use crate::transport::profile::{ - CodexRequestLocation, CodexResidency, CodexWireProfile, CodexWireProfileState, -}; +use crate::transport::profile::{CodexResidency, CodexWireProfile, CodexWireProfileState}; use crate::transport::session::{CodexSessionIdentity, CodexSessionIdentityError}; use crate::transport::websocket::CodexWebSocketPoolConfig; use crate::{ @@ -275,9 +273,6 @@ pub struct CodexWireProfileConfig { pub terminal: String, #[serde(default)] pub residency: Option, - /// 为空时保留客户端的地区、环境日期和时区。 - #[serde(default)] - pub location: Option, pub verified_at: DateTime, } @@ -293,7 +288,6 @@ impl Default for CodexWireProfileConfig { arch: "arm64".to_owned(), terminal: "unknown".to_owned(), residency: None, - location: None, // 制品核验于 2026-09-06T03:26:12.084Z;进程启动不构成重新核验。 verified_at: DateTime::UNIX_EPOCH + chrono::Duration::milliseconds(1_788_665_172_084), } @@ -325,29 +319,6 @@ impl CodexWireProfileConfig { return Err(OpenAiConfigError::InvalidField(field)); } } - 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( "openai.wire_profile.codex_version", @@ -379,7 +350,6 @@ impl From for CodexWireProfile { arch: value.arch, terminal: value.terminal, residency: value.residency, - location: value.location, verified_at: value.verified_at, } } diff --git a/backend/crates/providers/openai/src/provider/mod.rs b/backend/crates/providers/openai/src/provider/mod.rs index c23924ef0..25cd68bcf 100644 --- a/backend/crates/providers/openai/src/provider/mod.rs +++ b/backend/crates/providers/openai/src/provider/mod.rs @@ -72,7 +72,7 @@ use crate::transport::diagnostics::{ CodexFailureCategory, CodexUpstreamFailure, CodexUpstreamSendPhase, }; use crate::transport::profile::{ - APPCAST_POLL_INTERVAL, CodexDesktopReleaseService, CodexRequestLocation, CodexWireProfileState, + APPCAST_POLL_INTERVAL, CodexDesktopReleaseService, CodexWireProfileState, }; use crate::transport::protocol::responses::{ CodexResponsesRequest, PREVIOUS_RESPONSE_NOT_FOUND_CODE, PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE, @@ -80,7 +80,8 @@ use crate::transport::protocol::responses::{ }; use crate::transport::protocol::websocket::WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE; use crate::transport::request::{ - CodexRequestEncodeError, RequestAccountScope, encode_generate_request, scope_request_to_account, + CodexRequestEncodeError, RequestAccountScope, align_structured_location_fields, + encode_generate_request, scope_request_to_account, }; use crate::transport::session::CodexSessionIdentity; use crate::transport::usage::normalize_service_tier; @@ -141,7 +142,6 @@ pub struct CodexProvider { quota: Arc, account_feedback: Arc, client: CodexBackendClient, - location: Option, responses_url: Url, image_generations_url: Url, image_edits_url: Url, @@ -174,7 +174,6 @@ impl CodexProvider { .map_err(|_| CodexProviderConfigError::InvalidBaseUrl)?; let search_url = Url::parse(&endpoint_url(&base_url, CODEX_ALPHA_SEARCH_PATH)) .map_err(|_| CodexProviderConfigError::InvalidBaseUrl)?; - let location = profile.snapshot().location; let client = CodexBackendClient::new(http, base_url, profile).with_websocket_pool(websocket_pool); Ok(Self { @@ -183,7 +182,6 @@ impl CodexProvider { quota, account_feedback, client, - location, responses_url, image_generations_url, image_edits_url, @@ -256,8 +254,7 @@ impl Provider for CodexProvider { ..Default::default() }; }; - let Ok(encoded) = encode_generate_request(request, "observability", self.location.as_ref()) - else { + let Ok(encoded) = encode_generate_request(request, "observability", None) else { return ProviderRequestObservation::default(); }; let semantics = encoded.semantics(); @@ -358,9 +355,8 @@ 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.as_ref()) - .map_err(map_request_error)?; + let mut upstream_request = encode_generate_request(generate, upstream_model.as_str(), None) + .map_err(map_request_error)?; if let Some(conversation_id) = previous_session .as_ref() .and_then(|state| state.conversation_id.as_ref()) @@ -513,6 +509,18 @@ impl Provider for CodexProvider { lease.installation_id(), account_scope, ); + // 每次执行从原始请求编码,选定出口后再覆盖,避免换号时携带上次位置。 + if let Some(location) = lease + .account() + .request_location() + .or(context.request_location()) + { + align_structured_location_fields( + upstream_request.body_mut(), + chrono::Utc::now(), + location, + ); + } let requirement = transport_requirement(&upstream_request); let api_http = matches!(lease.authentication(), crate::credential::CodexRuntimeAuthentication::ApiKey(auth) if auth.configuration.transport == crate::credential::ApiKeyTransport::Http); diff --git a/backend/crates/providers/openai/src/transport/profile/mod.rs b/backend/crates/providers/openai/src/transport/profile/mod.rs index eddef345c..2e35571df 100644 --- a/backend/crates/providers/openai/src/transport/profile/mod.rs +++ b/backend/crates/providers/openai/src/transport/profile/mod.rs @@ -39,26 +39,8 @@ pub enum CodexResidency { Us, } -/// 环境上下文与 Web Search 共用的请求地区画像。 -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -pub struct CodexRequestLocation { - pub country: String, - pub region: String, - pub city: String, - pub timezone: chrono_tz::Tz, -} - -impl Default for CodexRequestLocation { - fn default() -> Self { - Self { - country: "US".to_owned(), - region: "Ohio".to_owned(), - city: "Piketon".to_owned(), - timezone: chrono_tz::America::New_York, - } - } -} +/// 保留已有配置类型入口;位置规则由出口领域统一维护。 +pub use gateway_core::account::RequestLocation as CodexRequestLocation; /// Codex Desktop 上游请求身份。 /// @@ -84,8 +66,6 @@ pub struct CodexWireProfile { pub terminal: String, /// 未配置时不发送 residency 头;不随制品版本更新而改变。 pub residency: Option, - /// 可选的地区覆盖;为空时透传,不随制品版本更新而改变。 - 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 91bed43c2..3384445c6 100644 --- a/backend/crates/providers/openai/src/transport/request.rs +++ b/backend/crates/providers/openai/src/transport/request.rs @@ -126,7 +126,7 @@ fn adapt_codex_responses_body( } } -fn align_structured_location_fields( +pub(crate) fn align_structured_location_fields( body: &mut Map, now: DateTime, location: &CodexRequestLocation, diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index 0b9e41d3f..ca30c69a0 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -1400,7 +1400,6 @@ fn valid_config() -> TestOpenAiConfig { arch: "arm64".to_owned(), terminal: "xterm-256color".to_owned(), residency: None, - location: Default::default(), verified_at: Utc .with_ymd_and_hms(2026, 7, 19, 0, 0, 0) .single() diff --git a/backend/crates/providers/openai/tests/config.rs b/backend/crates/providers/openai/tests/config.rs index c4c8e60e5..7a20ffa9e 100644 --- a/backend/crates/providers/openai/tests/config.rs +++ b/backend/crates/providers/openai/tests/config.rs @@ -3,74 +3,7 @@ use std::path::Path; use chrono::{TimeZone as _, Utc}; use provider_openai::config::{ CodexWireProfileConfig, DEFAULT_STREAM_MAX_RETRIES, MAX_STREAM_MAX_RETRIES, OpenAiConfig, - OpenAiConfigError, }; -use provider_openai::transport::profile::CodexRequestLocation; -use serde_json::json; - -#[test] -fn openai_location_should_default_to_passthrough() { - let profile = OpenAiConfig::default().wire_profile_state().snapshot(); - assert_eq!(profile.location, None); -} - -#[test] -fn openai_config_should_preserve_a_custom_location_in_the_runtime_profile() { - let location: CodexRequestLocation = serde_json::from_value(json!({ - "country": "NZ", "region": "Auckland", "city": "Auckland", "timezone": "Pacific/Auckland" - })) - .expect("location configuration"); - let mut config = valid_config(); - 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, - Some(location) - ); -} - -#[test] -fn openai_config_should_reject_invalid_location_fields() { - for (field, value) in [ - ("country", "USA"), - ("country", "us"), - ("country", ""), - ("region", " "), - ("city", ""), - ] { - let mut config = valid_config(); - let location = config - .wire_profile - .location - .insert(CodexRequestLocation::default()); - let (target, expected) = match field { - "country" => ( - &mut location.country, - "openai.wire_profile.location.country", - ), - "region" => (&mut location.region, "openai.wire_profile.location.region"), - _ => (&mut location.city, "openai.wire_profile.location.city"), - }; - *target = value.to_owned(); - assert_eq!( - config.resolve_and_validate(Path::new("/srv/gateway")), - Err(OpenAiConfigError::InvalidField(expected)) - ); - } -} - -#[test] -fn location_config_should_reject_unknown_timezones_and_incomplete_groups() { - for value in [ - json!({ "country": "US", "region": "Ohio", "city": "Piketon", "timezone": "Not/A_Timezone" }), - json!({ "timezone": "Pacific/Auckland" }), - json!({ "country": "US", "region": "Ohio", "city": "Piketon", "timezone": "America/New_York", "extra": true }), - ] { - assert!(serde_json::from_value::(value).is_err()); - } -} #[test] fn openai_config_builds_the_audited_wire_profile() { @@ -221,7 +154,6 @@ fn valid_config() -> OpenAiConfig { arch: "arm64".to_owned(), terminal: "xterm-256color".to_owned(), residency: None, - location: Default::default(), verified_at: Utc .with_ymd_and_hms(2026, 7, 19, 0, 0, 0) .single() diff --git a/backend/crates/providers/openai/tests/credential/catalog.rs b/backend/crates/providers/openai/tests/credential/catalog.rs index 94f1c838d..ec5141b30 100644 --- a/backend/crates/providers/openai/tests/credential/catalog.rs +++ b/backend/crates/providers/openai/tests/credential/catalog.rs @@ -65,7 +65,6 @@ fn wire_profile() -> CodexWireProfileState { arch: "x86_64".to_owned(), terminal: "catalog-contract".to_owned(), residency: None, - location: Default::default(), verified_at: Utc .with_ymd_and_hms(2026, 7, 18, 0, 0, 0) .single() diff --git a/backend/crates/providers/openai/tests/credential/contract.rs b/backend/crates/providers/openai/tests/credential/contract.rs index 50bc14fba..de13f5ad6 100644 --- a/backend/crates/providers/openai/tests/credential/contract.rs +++ b/backend/crates/providers/openai/tests/credential/contract.rs @@ -162,7 +162,6 @@ fn selector_with_runtime( arch: "x86_64".to_owned(), terminal: "selector-contract".to_owned(), residency: None, - location: Default::default(), verified_at: chrono::Utc::now(), }); let http = reqwest::Client::builder().build().expect("HTTP client"); diff --git a/backend/crates/providers/openai/tests/credential/profile_statistics.rs b/backend/crates/providers/openai/tests/credential/profile_statistics.rs index 26fee62c5..c84eda93d 100644 --- a/backend/crates/providers/openai/tests/credential/profile_statistics.rs +++ b/backend/crates/providers/openai/tests/credential/profile_statistics.rs @@ -22,7 +22,6 @@ fn wire_profile() -> CodexWireProfileState { arch: "x86_64".to_owned(), terminal: "profile-statistics-contract".to_owned(), residency: None, - location: Default::default(), verified_at: Utc .with_ymd_and_hms(2026, 8, 28, 0, 0, 0) .single() diff --git a/backend/crates/providers/openai/tests/credential/quota/mod.rs b/backend/crates/providers/openai/tests/credential/quota/mod.rs index ae25554e5..329e9956b 100644 --- a/backend/crates/providers/openai/tests/credential/quota/mod.rs +++ b/backend/crates/providers/openai/tests/credential/quota/mod.rs @@ -39,7 +39,6 @@ fn wire_profile() -> CodexWireProfileState { arch: "x86_64".to_owned(), terminal: "quota-contract".to_owned(), residency: None, - location: Default::default(), verified_at: Utc .with_ymd_and_hms(2026, 7, 18, 0, 0, 0) .single() diff --git a/backend/crates/providers/openai/tests/provider/contract.rs b/backend/crates/providers/openai/tests/provider/contract.rs index 2d4641099..08e70a843 100644 --- a/backend/crates/providers/openai/tests/provider/contract.rs +++ b/backend/crates/providers/openai/tests/provider/contract.rs @@ -66,6 +66,59 @@ use crate::support::{ }; use crate::transport::accept_codex_test_websocket; +#[tokio::test] +async fn selected_proxy_location_overrides_global_and_reloads_without_mutating_client_payload() { + use gateway_core::account::{OutboundProxy, RequestLocation}; + let store = Arc::new(MemoryAccountStore::default()); + let account_id = "acct_provider_contract"; + create_account(&store, account_id).await; + let first_proxy = MockServer::start().await; + let second_proxy = MockServer::start().await; + for proxy in [&first_proxy, &second_proxy] { + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(CAPTURE_COMPLETED_SSE), + ) + .mount(proxy) + .await; + } + // 目标不可解析;收到请求证明使用的是账号代理,而非测试机默认出口。 + let provider = provider_with_base_url(&store, "http://upstream.invalid".to_owned()); + let original = json!({"model":"gpt-5.4", "input":[ + {"role":"user", "content":[{"type":"input_text", "text":"UTC"}], "internal_chat_message_metadata_passthrough":{"content_item_kinds":["environments.environment_context"], "create_time":1789293131.822}}, + {"role":"user", "content":[{"type":"input_text", "text":"UTC"}], "internal_chat_message_metadata_passthrough":{"content_item_kinds":["user.text"]}} + ], "tools":[{"type":"web_search"}]}); + let operation = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object("openai", original.as_object().unwrap().clone()) + .unwrap() + .with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))])), + )); + for (index, (proxy, location, global_enabled, expected)) in [ + (&first_proxy, Some(json!({"country":"JP", "region":"Tokyo", "city":"Tokyo", "timezone":"Asia/Tokyo"})), true, Some("Asia/Tokyo")), + (&second_proxy, Some(json!({"country":"US", "region":"New York", "city":"New York", "timezone":"America/New_York"})), true, Some("America/New_York")), + (&second_proxy, None, true, Some("Pacific/Auckland")), + (&second_proxy, None, false, None), + (&first_proxy, Some(json!({"country":"JP", "region":"Tokyo", "city":"Tokyo", "timezone":"Asia/Tokyo"})), false, Some("Asia/Tokyo")), + ].into_iter().enumerate() { + let location = location.map(|value| serde_json::from_value::(value).unwrap()); + store.set_egress(account_id, Some(OutboundProxy::parse(&proxy.uri()).unwrap()), location); + let mut stream = provider.execute(planned_request("openai", operation.clone()), context_with_state_owner_and_location(&format!("req_proxy_location_{index}"), account_id, global_enabled.then(global_request_location))).await.expect("prepare"); + while let Some(event) = stream.next().await { event.expect("proxy response"); } + let requests = proxy.received_requests().await.unwrap(); + let body = captured_request_body(requests.last().expect("request reached selected proxy")); + assert_eq!(body.pointer("/tools/0/user_location/timezone"), expected.map(|timezone| json!(timezone)).as_ref()); + let expected = expected.unwrap_or("UTC"); + assert_eq!(body.pointer("/input/0/content/0/text"), Some(&json!(format!("{expected}")))); + assert_eq!(body.pointer("/input/1/content/0/text"), original.pointer("/input/1/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); + assert_eq!(second_proxy.received_requests().await.unwrap().len(), 3); +} + const OFFICIAL_FIXTURE: &[u8] = include_bytes!("../transport/fixtures/official_models_snapshot.json"); const CAPTURE_COMPLETED_SSE: &str = concat!( @@ -139,9 +192,6 @@ fn wire_profile() -> CodexWireProfileState { arch: "x86_64".to_owned(), terminal: "provider-contract".to_owned(), residency: None, - location: serde_json::from_value(json!({ - "country": "NZ", "region": "Auckland", "city": "Auckland", "timezone": "Pacific/Auckland" - })).expect("configured request location"), verified_at: Utc::now(), }) } @@ -514,12 +564,22 @@ fn contract_account_scope() -> Arc { )) } +fn global_request_location() -> gateway_core::account::RequestLocation { + gateway_core::account::RequestLocation { + country: "NZ".to_owned(), + region: "Auckland".to_owned(), + city: "Auckland".to_owned(), + timezone: "Pacific/Auckland".parse().expect("valid timezone"), + } +} + fn context(request_id: &str, cancellation: CancellationToken) -> AttemptContext { AttemptContext::new( RequestAttemptContext::new( ModelRequestId::new(request_id).expect("request id"), ClientApiKeyId::new("key_openai_contract").expect("client key id"), - ), + ) + .with_request_location(Some(global_request_location())), NonZeroU32::new(1).expect("attempt"), SystemTime::now() + Duration::from_secs(30), account_policy(), @@ -539,7 +599,8 @@ fn diagnostic_context(request_id: &str, account_id: &str) -> AttemptContext { RequestAttemptContext::new( ModelRequestId::new(request_id).expect("request id"), ClientApiKeyId::new("key_openai_contract").expect("client key id"), - ), + ) + .with_request_location(Some(global_request_location())), NonZeroU32::new(1).expect("attempt"), SystemTime::now() + Duration::from_secs(30), account_policy(), @@ -554,6 +615,18 @@ fn diagnostic_context(request_id: &str, account_id: &str) -> AttemptContext { } fn context_with_state_owner(request_id: &str, owner_account_id: &str) -> AttemptContext { + context_with_state_owner_and_location( + request_id, + owner_account_id, + Some(global_request_location()), + ) +} + +fn context_with_state_owner_and_location( + request_id: &str, + owner_account_id: &str, + location: Option, +) -> AttemptContext { let owner = ProviderAccountStateOwner::new( ProviderKind::new("openai").expect("provider"), ProviderAccountId::new(owner_account_id).expect("owner account id"), @@ -562,7 +635,8 @@ fn context_with_state_owner(request_id: &str, owner_account_id: &str) -> Attempt RequestAttemptContext::new( ModelRequestId::new(request_id).expect("request id"), ClientApiKeyId::new("key_openai_contract").expect("client key id"), - ), + ) + .with_request_location(location), NonZeroU32::new(1).expect("attempt"), SystemTime::now() + Duration::from_secs(30), account_policy(), @@ -588,7 +662,8 @@ fn replay_any_context(request_id: &str, owner_account_id: &str) -> AttemptContex RequestAttemptContext::new( ModelRequestId::new(request_id).expect("request id"), ClientApiKeyId::new("key_openai_contract").expect("client key id"), - ), + ) + .with_request_location(Some(global_request_location())), NonZeroU32::new(2).expect("attempt"), SystemTime::now() + Duration::from_secs(30), account_policy(), @@ -622,7 +697,8 @@ fn pinned_continuation_context( RequestAttemptContext::new( ModelRequestId::new(request_id).expect("request id"), ClientApiKeyId::new("key_openai_contract").expect("client key id"), - ), + ) + .with_request_location(Some(global_request_location())), NonZeroU32::new(attempt_index).expect("attempt index"), SystemTime::now() + Duration::from_secs(30), account_policy(), @@ -639,7 +715,8 @@ fn external_continuation_context(request_id: &str) -> AttemptContext { RequestAttemptContext::new( ModelRequestId::new(request_id).expect("request id"), ClientApiKeyId::new("key_openai_contract").expect("client key id"), - ), + ) + .with_request_location(Some(global_request_location())), NonZeroU32::new(1).expect("attempt"), SystemTime::now() + Duration::from_secs(30), account_policy(), @@ -697,7 +774,7 @@ async fn capture_scoped_http_request( } #[tokio::test] -async fn provider_should_send_the_configured_location_to_the_upstream() { +async fn provider_should_send_the_request_snapshot_location_to_the_upstream() { let body = json!({ "model": "gpt-5.4", "input": [{ diff --git a/backend/crates/providers/openai/tests/support.rs b/backend/crates/providers/openai/tests/support.rs index 18f8c9b98..0b09601a8 100644 --- a/backend/crates/providers/openai/tests/support.rs +++ b/backend/crates/providers/openai/tests/support.rs @@ -129,6 +129,22 @@ impl MemoryAccountStore { .with_scheduling(concurrency_limit, weight); } + pub(crate) fn set_egress( + &self, + id: &str, + proxy: Option, + location: Option, + ) { + let id = ProviderAccountId::new(id).expect("account ID"); + let mut accounts = self.accounts.lock().expect("account store lock"); + let stored = accounts.get_mut(&id).expect("seeded account"); + stored.account = stored + .account + .clone() + .with_outbound_proxy(proxy) + .with_request_location(location); + } + pub(crate) fn quota_reads(&self) -> usize { self.quota_reads.load(Ordering::SeqCst) } diff --git a/backend/crates/providers/openai/tests/transport/catalog.rs b/backend/crates/providers/openai/tests/transport/catalog.rs index d603c8ff3..e8bcb23dd 100644 --- a/backend/crates/providers/openai/tests/transport/catalog.rs +++ b/backend/crates/providers/openai/tests/transport/catalog.rs @@ -419,7 +419,6 @@ fn profile() -> CodexWireProfileState { arch: "x86_64".to_owned(), terminal: "xterm".to_owned(), residency: None, - location: Default::default(), verified_at: Utc .with_ymd_and_hms(2026, 7, 18, 0, 0, 0) .single() diff --git a/backend/crates/providers/openai/tests/transport/mod.rs b/backend/crates/providers/openai/tests/transport/mod.rs index b0c70ec8e..aff75a752 100644 --- a/backend/crates/providers/openai/tests/transport/mod.rs +++ b/backend/crates/providers/openai/tests/transport/mod.rs @@ -132,7 +132,6 @@ fn test_wire_profile() -> CodexWireProfileState { arch: "x86_64".to_owned(), terminal: "transport-test".to_owned(), residency: None, - location: Default::default(), verified_at: Utc::now(), }) } diff --git a/backend/crates/providers/openai/tests/transport/profile/mod.rs b/backend/crates/providers/openai/tests/transport/profile/mod.rs index 4b54f8ad9..e9b879709 100644 --- a/backend/crates/providers/openai/tests/transport/profile/mod.rs +++ b/backend/crates/providers/openai/tests/transport/profile/mod.rs @@ -141,7 +141,6 @@ fn wire_profile_should_generate_bundled_core_app_server_user_agent() { arch: "arm64".to_owned(), terminal: "unknown".to_owned(), residency: None, - location: Default::default(), verified_at: Utc .with_ymd_and_hms(2026, 8, 3, 0, 0, 0) .single() @@ -208,11 +207,7 @@ fn desktop_appcast_should_reject_invalid_version_and_build() { #[tokio::test] async fn bundled_release_update_should_change_core_and_desktop_identity_atomically() { - let mut original = wire_profile(); - original.location = serde_json::from_value(serde_json::json!({ - "country": "NZ", "region": "Auckland", "city": "Auckland", "timezone": "Pacific/Auckland" - })) - .expect("configured location"); + let original = wire_profile(); let original_user_agent = original.user_agent(); let state = CodexWireProfileState::new(original.clone()); let transport = Arc::new(ReleaseTransport::new( @@ -233,7 +228,6 @@ async fn bundled_release_update_should_change_core_and_desktop_identity_atomical assert_eq!(updated.os_version, original.os_version); assert_eq!(updated.arch, original.arch); assert_eq!(updated.terminal, original.terminal); - assert_eq!(updated.location, original.location); assert!(updated.verified_at > original.verified_at); assert_ne!(updated.user_agent(), original_user_agent); assert_eq!( @@ -350,7 +344,6 @@ fn wire_profile() -> CodexWireProfile { arch: "arm64".to_owned(), terminal: "unknown".to_owned(), residency: None, - location: Default::default(), verified_at: Utc .with_ymd_and_hms(2026, 8, 3, 0, 0, 0) .single() diff --git a/backend/crates/providers/openai/tests/transport/reset_credits.rs b/backend/crates/providers/openai/tests/transport/reset_credits.rs index dc768bec8..173311ce1 100644 --- a/backend/crates/providers/openai/tests/transport/reset_credits.rs +++ b/backend/crates/providers/openai/tests/transport/reset_credits.rs @@ -27,7 +27,6 @@ fn client(base_url: &str) -> CodexBackendClient { arch: "arm64".to_owned(), terminal: "xterm-256color".to_owned(), residency: None, - location: Default::default(), verified_at: Utc .with_ymd_and_hms(2026, 8, 20, 0, 0, 0) .single() diff --git a/backend/crates/providers/openai/tests/transport/usage.rs b/backend/crates/providers/openai/tests/transport/usage.rs index c7dbf1b39..987321f39 100644 --- a/backend/crates/providers/openai/tests/transport/usage.rs +++ b/backend/crates/providers/openai/tests/transport/usage.rs @@ -705,7 +705,6 @@ fn profile() -> CodexWireProfileState { arch: "x86_64".to_owned(), terminal: "xterm".to_owned(), residency: None, - location: Default::default(), verified_at: Utc .with_ymd_and_hms(2026, 7, 18, 0, 0, 0) .single() diff --git a/backend/migrations/.frozen-sha256 b/backend/migrations/.frozen-sha256 index e59e5c8d0..e9b314ae7 100644 --- a/backend/migrations/.frozen-sha256 +++ b/backend/migrations/.frozen-sha256 @@ -9,3 +9,4 @@ b7d3df7b48d835a00a3b74cbefc958a039d38fea5529cdad04bfbc703dca72aa 0001_initial.s 161df9ae43da34d51b340bbf95d71c61a8c1a2aa142382f693a9d127c834630d 0009_account_model_access.sql dd4aa34e9bcf1698bb9688421964bbf410314d8e905448b8229460b972cff2f6 0010_account_auto_freeze.sql 9bd261b7e94872126754eb7f749c56187264a47c567b0bef5c394d6776b4f463 0011_account_auto_freeze_opt_in.sql +ae7f7acb3014f0a60d979dd9c04b3065322b206e42eb6256505baf78caf9dd22 0012_proxy_request_location.sql diff --git a/backend/migrations/0012_proxy_request_location.sql b/backend/migrations/0012_proxy_request_location.sql new file mode 100644 index 000000000..83c8655a4 --- /dev/null +++ b/backend/migrations/0012_proxy_request_location.sql @@ -0,0 +1,35 @@ +-- 位置配置由代理持有;现有代理保持全局继承,不回填账号或凭据。 +alter table outbound_proxies + add column location_country text, + add column location_region text, + add column location_city text, + add column location_timezone text, + add constraint outbound_proxies_location_complete check ( + num_nonnulls(location_country, location_region, location_city, location_timezone) = 0 + or ( + num_nonnulls(location_country, location_region, location_city, location_timezone) = 4 + and location_country ~ '^[A-Z]{2}$' + and char_length(btrim(location_region)) between 1 and 128 + and char_length(btrim(location_city)) between 1 and 128 + and char_length(location_timezone) > 0 + ) + ); + +-- 全局位置属于运行设置;初始值仅在开启全局覆盖后生效,关闭时保留客户端字段。 +alter table runtime_settings + add column request_location_enabled boolean not null default false, + add column request_location_json jsonb not null default + '{"country":"US","region":"Ohio","city":"Piketon","timezone":"America/New_York"}'::jsonb, + add constraint runtime_settings_request_location_valid check ( + jsonb_typeof(request_location_json) = 'object' + and request_location_json ?& array['country', 'region', 'city', 'timezone'] + and request_location_json - array['country', 'region', 'city', 'timezone'] = '{}'::jsonb + and jsonb_typeof(request_location_json->'country') = 'string' + and request_location_json->>'country' ~ '^[A-Z]{2}$' + and jsonb_typeof(request_location_json->'region') = 'string' + and char_length(btrim(request_location_json->>'region')) between 1 and 128 + and jsonb_typeof(request_location_json->'city') = 'string' + and char_length(btrim(request_location_json->>'city')) between 1 and 128 + and jsonb_typeof(request_location_json->'timezone') = 'string' + and char_length(request_location_json->>'timezone') > 0 + ); diff --git a/deploy/README.md b/deploy/README.md index 0b633724b..d47c1efbe 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -50,12 +50,14 @@ Linux 上应用容器以 `10001:10001` 运行。上述命令将应用数据和 模板不重复列出这些默认项。运行后,Provider 检查官方版本并更新运行时请求画像, 不回写 `config.yaml`;检查失败时继续使用上一份有效画像。版本检查不等于重新核验 TLS。 -`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` 约束或随官方版本检查变化。 +全局请求位置在管理端「系统设置 → 运行设置 → 请求时区与位置」配置,默认关闭,保留客户端原有位置和时区。 +开启后使用自定义位置,关闭时保留已保存的自定义值。「代理管理」设置的自定义位置优先于全局设置, +全局关闭时代理自定义仍生效。保存后新请求生效,无需重启;关闭代理自定义后恢复全局继承。 +该设置统一 OpenAI Responses 的 Web Search 位置与带来源标记的环境日期、时区,不修改 epoch 时间戳、 +真实出口 IP 或服务系统时区。字段约束见 [运行设置](../docs/api.md#8-运行设置)。 + +`config.yaml` 不再接受 `openai.wire_profile.location`。升级时将原有自定义值填写到管理端全局设置并开启开关, +并从配置文件删除该项;数据库初始化不会从配置文件自动导入旧值。 ## 启动 diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 9da4a314b..21e84e2f5 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -78,9 +78,6 @@ openai: terminal: 'unknown' # 部署选择的 US residency;省略或 null 时使用官方无地域约束的默认分支。 residency: us - # 请求地区覆盖;省略或留空(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' xai: diff --git a/docs/api.md b/docs/api.md index 131812788..448213e36 100644 --- a/docs/api.md +++ b/docs/api.md @@ -428,12 +428,12 @@ Images、独立 Search 及管理员连接测试不受该文本模型限制;连 | `GET` | `/api/admin/proxies` | `page`、`pageSize`(1-200)、`search`(名称) | `{ items, page }` | | `GET` | `/api/admin/proxies/accounts` | `proxyId`、`page`、`pageSize`(1-200)、`search`(账号名称或邮箱) | `{ items, page }` | | `POST` | `/api/admin/proxies/accounts/remove` | `{ proxyId, accountId }` | `{ configRevision }` | -| `POST` | `/api/admin/proxies/create` | `{ name, proxyUrl }` | `201 { record, configRevision }` | -| `POST` | `/api/admin/proxies/update` | `{ id, revision, name, proxyUrl? }` | `{ record, configRevision }` | +| `POST` | `/api/admin/proxies/create` | `{ name, proxyUrl, location? }` | `201 { record, configRevision }` | +| `POST` | `/api/admin/proxies/update` | `{ id, revision, name, proxyUrl?, location? }` | `{ record, configRevision }` | | `POST` | `/api/admin/proxies/test` | `{ id, revision }` | 最新代理记录 / Proxy record with test result | | `POST` | `/api/admin/proxies/delete` | `{ id, revision }` | `{ configRevision }` | -`record` 包含 `id`、`name`、`endpoint`、`hasAuthentication`、`revision`、`accountCount`、 +`record` 包含 `id`、`name`、`endpoint`、`hasAuthentication`、`revision`、`accountCount`、`location`、 `lastTestAt`、`lastTest: { success, latencyMs, exitIp, message }`、`createdAt`、`updatedAt`。 未测试时 `lastTestAt` / `lastTest` 为 `null`。连通性失败返回 HTTP 200 和 `lastTest.success=false`; 记录版本过期、重复 URL、删除已绑定的代理返回 409,并发测试满载返回 429。 @@ -449,6 +449,17 @@ Images、独立 Search 及管理员连接测试不受该文本模型限制;连 更新省略 `proxyUrl` 保留认证;连接配置改变时清除测试结果并更新所有绑定账号。 测试结果只在请求中的版本仍匹配时保存。 +`location` 为 `null` 或完整对象 `{ country, region, city, timezone }`。国家代码为两位大写 ASCII 字母; +地区、城市禁止控制字符,去除首尾空白后须为 1–128 个字符;时区必须是有效 IANA 名称,例如 `Asia/Tokyo`。 +创建时省略或 `null` 表示继承全局;更新时省略表示保留,`null` 清除覆盖,完整对象替换覆盖。 +只改位置不清空连通性测试结果,也不更改账号凭据版本。 + +关联账号的 OpenAI/Codex Responses 请求(HTTP/SSE、WebSocket)优先使用代理位置,否则使用全局 +运行设置中已开启的 `requestLocation`;两者均未开启时保留客户端原有位置和时区。全局覆盖按请求冻结, +新请求使用保存后的设置,无需重启;代理覆盖在每次执行时读取, +换号或换出口按该次选定账号解析。位置只影响带来源标记的环境上下文日期/时区和 Web Search 的结构化位置, +不改变用户普通文本、epoch 时间戳、真实出口 IP、服务或管理端时区、数据驻留约束及 xAI 请求。 + 测试固定经代理访问双栈端点 `https://api64.ipify.org?format=json`,返回本次连接实际使用的 IPv4 或 IPv6 出口地址, 不分别验证两种地址族的连通性。超时 15 秒,每进程最多同时测试 4 条。 探测器复用 OpenAI 的证书信任配置:优先读取非空的 `CODEX_CA_CERTIFICATE`, @@ -904,6 +915,8 @@ HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly 设置更新字段包括: ```text +requestLocationEnabled +requestLocation modelMappings refreshMarginSeconds refreshConcurrency @@ -927,6 +940,14 @@ accountAutoFreezeProbeModel accountAutoFreezeAdaptiveConcurrency ``` +`requestLocationEnabled` 是必填布尔值,默认 `false`:关闭时不覆盖客户端原有位置和时区;开启时使用已保存的 +`requestLocation`。关闭不会清空自定义值,代理自定义位置仍优先。 +`requestLocation` 是必填的完整对象 `{ country, region, city, timezone }`,不接受 `null`;初始保存值为 +`{ country: "US", region: "Ohio", city: "Piketon", timezone: "America/New_York" }`。 +字段约束与[代理位置](#独立代理管理--managed-proxies)一致。全局自定义开启后,OpenAI Responses 使用全局位置, +关联代理配置了自定义位置时优先使用代理值。保存后通过现有配置发布机制对新请求生效, +已开始请求及其重试保持同一份全局值;普通文本、绝对时间戳和数据驻留要求不受影响。 + `maxWaitingPerKey` 与 `maxWaitingPerAccount` 是全局统一的排队容量,取值 0~1,000,默认 0(关闭); 每个 Key、每个账号各自独立计数,没有单对象覆盖字段。执行并发为 5、最大排队数为 5 时, 该对象最多容纳 5 个执行请求与 5 个等待请求。Key 并发为 0(不限)时跳过 Key 排队。 diff --git a/docs/architecture.md b/docs/architecture.md index 0d7428006..41a0a530e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -129,6 +129,10 @@ Provider 模型能力、目录代次与 `ProviderCatalogPort` 由 `routing::cata OpenAI 的账号/凭据 revision/客户端版本隔离、并发合并、TTL、容量与失效均归属已有 credential catalog service;客户端原生对象只进有界进程缓存,不扩展套餐 Redis ID cache 或持久化模型字段副本。 +全局请求位置及开关由运行设置持久化;快照仅在开关开启时生成全局覆盖,并随 +`RuntimeSnapshot → RoutingPlan → AttemptContext` 冻结传递,关闭时保留客户端原有字段; +Provider 在选定账号后应用代理位置覆盖。请求期间不额外查询全局设置,配置发布不改变已开始请求的全局值。 + `engine::observation` 统一维护单次响应的用量、费用、时间和响应 ID,并负责重试前清理;协调器继续 独占发送、提交、重试和终结顺序。Provider 上报费用优先于本地估算,丢弃的 attempt 不得污染最终计量。 Client Key 费用账本独立累计各次 attempt 的实际费用,不能因请求重试而清空已产生的费用或未知计费状态。 diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 2d1f9ea21..4e54ead52 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -26,3 +26,5 @@ export * from './modules/system' // 使用记录 export * from './modules/usage' + +export type { RequestLocation } from './types/request-location' diff --git a/frontend/src/api/modules/proxies.ts b/frontend/src/api/modules/proxies.ts index f42b04ddc..4f66a6884 100644 --- a/frontend/src/api/modules/proxies.ts +++ b/frontend/src/api/modules/proxies.ts @@ -1,4 +1,5 @@ import type { RequestOptions } from '../request' +import type { RequestLocation } from '../types/request-location' import type { AccountGroupRef } from './account-groups' import request from '../request' @@ -10,6 +11,7 @@ export interface OutboundProxyTest { } export interface OutboundProxyRecord { + location: RequestLocation | null id: string name: string endpoint: string @@ -75,7 +77,7 @@ export function getProxies(data: { page: number, pageSize: number, search?: stri }) } -export function createProxy(data: { name: string, proxyUrl: string }) { +export function createProxy(data: { name: string, proxyUrl: string, location?: RequestLocation | null }) { return request({ url: '/api/admin/proxies/create', method: 'POST', @@ -83,7 +85,7 @@ export function createProxy(data: { name: string, proxyUrl: string }) { }) } -export function updateProxy(data: { id: string, revision: number, name: string, proxyUrl?: string }) { +export function updateProxy(data: { id: string, revision: number, name: string, proxyUrl?: string, location?: RequestLocation | null }) { return request({ url: '/api/admin/proxies/update', method: 'POST', diff --git a/frontend/src/api/modules/settings.ts b/frontend/src/api/modules/settings.ts index 34ff6a17e..913688e19 100644 --- a/frontend/src/api/modules/settings.ts +++ b/frontend/src/api/modules/settings.ts @@ -1,9 +1,12 @@ import type { RequestOptions } from '../request' +import type { RequestLocation } from '../types/request-location' import request from '../request' export type RotationStrategy = 'smart' | 'quota_reset_priority' | 'round_robin' | 'sticky' export interface RuntimeSettings { + requestLocationEnabled: boolean + requestLocation: RequestLocation modelMappings: Record refreshMarginSeconds: number refreshConcurrency: number diff --git a/frontend/src/api/types/request-location.ts b/frontend/src/api/types/request-location.ts new file mode 100644 index 000000000..9bad392d8 --- /dev/null +++ b/frontend/src/api/types/request-location.ts @@ -0,0 +1,6 @@ +export interface RequestLocation { + country: string + region: string + city: string + timezone: string +} diff --git a/frontend/src/components/RequestLocationFields.vue b/frontend/src/components/RequestLocationFields.vue new file mode 100644 index 000000000..9f9707f60 --- /dev/null +++ b/frontend/src/components/RequestLocationFields.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/utils/request-location.ts b/frontend/src/utils/request-location.ts new file mode 100644 index 000000000..1ce976c75 --- /dev/null +++ b/frontend/src/utils/request-location.ts @@ -0,0 +1,16 @@ +import type { RequestLocation } from '@/api' + +export function normalizeRequestLocation(location: RequestLocation): RequestLocation { + return { + country: location.country.trim().toUpperCase(), + region: location.region.trim(), + city: location.city.trim(), + timezone: location.timezone.trim(), + } +} + +export function requestLocationError(location: RequestLocation): string { + if (!/^[A-Z]{2}$/.test(location.country) || !location.region || !location.city || !location.timezone) + return '请填写两位国家代码、地区、城市和 IANA 时区' + return '' +} diff --git a/frontend/src/views/proxies/components/ProxyFormModal.vue b/frontend/src/views/proxies/components/ProxyFormModal.vue index f64ee0585..97bd264cf 100644 --- a/frontend/src/views/proxies/components/ProxyFormModal.vue +++ b/frontend/src/views/proxies/components/ProxyFormModal.vue @@ -1,5 +1,5 @@ + + diff --git a/frontend/src/views/settings/composables/useSettingsForm.ts b/frontend/src/views/settings/composables/useSettingsForm.ts index 89982f04f..99b111604 100644 --- a/frontend/src/views/settings/composables/useSettingsForm.ts +++ b/frontend/src/views/settings/composables/useSettingsForm.ts @@ -1,4 +1,5 @@ import type { rotationOptions } from '../constants' +import type { RequestLocation } from '@/api' import { computed, reactive, ref, shallowRef } from 'vue' import { getSettings, updateSettings } from '@/api' @@ -6,6 +7,7 @@ import { ApiError } from '@/api/request' import { toast } from '@/components/base/BaseToast' import { useAsyncAction } from '@/composables/useAsyncAction' import { errorMessage } from '@/utils/async' +import { normalizeRequestLocation, requestLocationError } from '@/utils/request-location' type RotationStrategy = (typeof rotationOptions)[number]['value'] @@ -15,7 +17,10 @@ export function useSettingsForm() { const saving = saveAction.loading const error = shallowRef('') const mappings = ref>([]) + const savedRequestLocation = shallowRef() const form = reactive({ + requestLocationEnabled: false, + requestLocation: { country: '', region: '', city: '', timezone: '' }, refreshMarginSeconds: null as number | null, refreshConcurrency: null as number | null, maxConcurrentPerAccount: null as number | null, @@ -74,6 +79,9 @@ export function useSettingsForm() { } function applySettings(data: Awaited>) { + savedRequestLocation.value = { ...data.requestLocation } + form.requestLocationEnabled = data.requestLocationEnabled + form.requestLocation = { ...data.requestLocation } form.refreshMarginSeconds = data.refreshMarginSeconds form.refreshConcurrency = data.refreshConcurrency form.maxConcurrentPerAccount = data.maxConcurrentPerAccount @@ -148,7 +156,7 @@ export function useSettingsForm() { } async function saveSettings() { - if (saving.value || loading.value) + if (saving.value || loading.value || !savedRequestLocation.value) return const { refreshMarginSeconds, refreshConcurrency, maxConcurrentPerAccount, requestIntervalMs, rotationStrategy, maxWaitingPerKey, maxWaitingPerAccount, concurrencyWaitTimeoutSeconds, accountAutoFreezeThreshold, accountAutoFreezeWindowSeconds, accountAutoFreezeDurationSeconds } = form if (refreshMarginSeconds === null || refreshConcurrency === null || maxConcurrentPerAccount === null || requestIntervalMs === null || !rotationStrategy || maxWaitingPerKey === null || maxWaitingPerAccount === null || concurrencyWaitTimeoutSeconds === null) { @@ -164,6 +172,15 @@ export function useSettingsForm() { toast.warning('请修正客户端最低版本格式') return } + // 关闭时保留已保存的自定义值,未完成的草稿不阻止停止覆盖。 + const requestLocation = form.requestLocationEnabled + ? normalizeRequestLocation(form.requestLocation) + : savedRequestLocation.value + const locationError = requestLocationError(requestLocation) + if (locationError) { + toast.warning(locationError) + return + } if (accountAutoFreezeThreshold === null || accountAutoFreezeWindowSeconds === null || accountAutoFreezeDurationSeconds === null) { toast.warning('请完整填写账号自动冻结参数') return @@ -181,6 +198,8 @@ export function useSettingsForm() { } await saveAction.run(async () => { const result = await updateSettings({ + requestLocationEnabled: form.requestLocationEnabled, + requestLocation, modelMappings: mappingPayload(), refreshMarginSeconds, refreshConcurrency, diff --git a/frontend/src/views/settings/index.vue b/frontend/src/views/settings/index.vue index d17488ef0..517018a3f 100644 --- a/frontend/src/views/settings/index.vue +++ b/frontend/src/views/settings/index.vue @@ -13,6 +13,7 @@ import AdminApiKeyCard from './components/AdminApiKeyCard.vue' import SettingsBackupSection from './components/backup/SettingsBackupSection.vue' import ClientVersionSettings from './components/client-version/index.vue' import ModelAliasesCard from './components/ModelAliasesCard.vue' +import RequestLocationCard from './components/RequestLocationCard.vue' import RotationStrategyCard from './components/RotationStrategyCard.vue' import RuntimeSettingsCard from './components/RuntimeSettingsCard.vue' import { useAdminApiKey } from './composables/useAdminApiKey' @@ -140,6 +141,7 @@ watch( v-model:concurrency-wait-timeout-seconds="concurrencyWaitTimeoutSecondsValue" /> +