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 <noreply@openai.com>

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Clarence12138
2026-09-17 03:30:54 +08:00
committed by GitHub
co-authored by Codex Zyy
parent 499b43043e
commit bd61e0391a
72 changed files with 1405 additions and 234 deletions
+1
View File
@@ -1510,6 +1510,7 @@ dependencies = [
"async-trait",
"base64 0.23.1",
"bytes",
"chrono-tz",
"futures",
"futures-timer",
"semver",
+14 -28
View File
@@ -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::<Vec<_>>()
.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) {
@@ -65,6 +65,7 @@ pub struct ProxyTestResult {
#[derive(Debug, Clone)]
pub struct ProxyRecord {
pub location: Option<gateway_core::account::RequestLocation>,
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<gateway_core::account::RequestLocation>,
pub name: String,
pub proxy: OutboundProxy,
}
#[derive(Debug, Clone)]
pub struct UpdateProxy {
/// 外层为空保留配置,内层为空恢复全局继承。
pub location: Option<Option<gateway_core::account::RequestLocation>>,
pub id: String,
pub revision: Revision,
pub name: String,
@@ -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,
@@ -181,6 +181,11 @@ impl ProxiesService for DefaultProxiesService {
context: &MutationContext,
) -> Result<ProxyMutation, AdminError> {
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<ProxyMutation, AdminError> {
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)
@@ -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
@@ -973,6 +973,8 @@ struct StaticSettingsStore;
impl SettingsStore for StaticSettingsStore {
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Ok(RuntimeSettings {
request_location_enabled: false,
request_location: Default::default(),
config_revision: revision(1),
model_mappings: Default::default(),
refresh_margin_seconds: 300,
@@ -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,
@@ -753,6 +753,8 @@ struct FixtureSettingsStore;
impl SettingsStore for FixtureSettingsStore {
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
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,
@@ -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,
@@ -48,6 +48,7 @@ struct RemoveAccountRequest {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct CreateRequest {
location: Option<gateway_core::account::RequestLocation>,
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<Option<gateway_core::account::RequestLocation>>,
id: String,
revision: u64,
name: String,
proxy_url: Option<AccountProxyUpdate>,
}
fn deserialize_location_update<'de, D>(
deserializer: D,
) -> Result<Option<Option<gateway_core::account::RequestLocation>>, 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<gateway_core::account::RequestLocation>,
id: String,
name: String,
endpoint: String,
@@ -127,6 +140,7 @@ impl From<ProxyRecord> 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,
@@ -34,6 +34,8 @@ pub type ModelMappings = BTreeMap<String, String>;
#[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<ReplaceRuntimeSettings, WireValidationError> {
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<RuntimeSettings> 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(),
@@ -402,6 +402,8 @@ impl SettingsStore for MemorySettingsStore {
) -> AdminStoreResult<RuntimeSettings> {
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,
@@ -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<Option<ProxyRecord>>);
@@ -98,6 +163,7 @@ impl ProxyStore for MemoryProxies {
_: &MutationContext,
) -> AdminStoreResult<ProxyMutation> {
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;
}
@@ -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
);
}
+1
View File
@@ -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"] }
@@ -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, InvalidRequestLocation> {
self.validate()?;
self.region = self.region.trim().to_owned();
self.city = self.city.trim().to_owned();
Ok(self)
}
}
@@ -1,6 +1,8 @@
//! Provider 账号领域、持久化端口与同一 target 内的账号选择。
mod error;
mod location;
pub use location::{InvalidRequestLocation, RequestLocation};
mod model;
mod model_access;
pub use model_access::{
@@ -744,6 +744,7 @@ pub struct ProviderAccount {
next_refresh_at: Option<SystemTime>,
has_refresh_token: bool,
outbound_proxy: Option<super::OutboundProxy>,
request_location: Option<super::RequestLocation>,
}
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<super::OutboundProxy>) -> 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<super::RequestLocation>) -> 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,
@@ -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()),
@@ -314,6 +314,7 @@ impl ContinuationAttempt {
/// Provider 每次执行可见的 request-local context。
#[derive(Debug, Clone)]
pub struct RequestAttemptContext {
request_location: Option<crate::account::RequestLocation>,
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<crate::account::RequestLocation>,
) -> 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 {
@@ -536,6 +536,7 @@ impl ProviderCandidate {
/// 一次请求冻结的 Provider 尝试顺序。
#[derive(Debug, Clone)]
pub struct RoutingPlan {
request_location: Option<crate::account::RequestLocation>,
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
@@ -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<String>,
) -> 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<crate::account::RequestLocation>,
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<crate::account::RequestLocation>,
) -> 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)
@@ -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::<RequestLocation>(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::<RequestLocation>(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::<RequestLocation>(json!({
"country": "JP", "region": "Tokyo", "city": "Tokyo", "timezone": timezone
}))
.is_err()
);
}
assert!(serde_json::from_value::<RequestLocation>(json!({"country": "JP"})).is_err());
}
@@ -1,3 +1,4 @@
mod location;
mod model_access;
mod proxy;
mod selection;
@@ -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))
);
}
@@ -290,3 +290,85 @@ fn compiler(store: Arc<dyn SnapshotStorePort>) -> 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"
);
}
@@ -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::<AdminStoreResult<ModelMappings>>()?;
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,
@@ -164,7 +164,7 @@ impl AccountGroupStore for PgAccountGroupRepository {
.map(|group_id| group_id.as_str().to_owned())
.collect::<Vec<_>>();
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[])
@@ -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。
@@ -109,13 +109,15 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
include_disabled: bool,
) -> StoreResult<Vec<ProviderAccountSummary>> {
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",
)
@@ -120,6 +120,7 @@ pub(crate) fn parse_error_reason(value: Option<String>) -> StoreResult<Option<Ac
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderAccountSummary {
pub request_location: Option<gateway_core::account::RequestLocation>,
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
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::<Option<String>>(&row, "outbound_proxy_url")?
.map(|url| {
gateway_core::account::OutboundProxy::parse(&url)
@@ -98,6 +98,7 @@ fn record(row: PgRow) -> StoreResult<ProxyRecord> {
let ip: Option<String> = row.try_get("last_test_ip").map_err(|_| invalid())?;
let latency: Option<i64> = 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<ProxyRecord> {
})
}
pub(crate) fn location_from_row(
row: &PgRow,
) -> StoreResult<Option<gateway_core::account::RequestLocation>> {
let country: Option<String> = 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::<String, _>("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
@@ -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<String, String>,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
@@ -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<String, String>,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
@@ -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<RuntimeSettings> {
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<RuntimeSettings> {
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<gateway_core::account::RequestLocation>,
model_mappings_json: sqlx::types::Json<BTreeMap<String, String>>,
usage_retention_days: i64,
ops_event_retention_days: i64,
@@ -444,6 +465,12 @@ fn runtime_settings_from_row(row: RuntimeSettingsRow) -> StoreResult<RuntimeSett
max_concurrent_per_account: to_u32(row.max_concurrent_per_account)?,
request_interval_ms: to_u64(row.request_interval_ms)?,
rotation_strategy: row.rotation_strategy,
request_location_enabled: row.request_location_enabled,
request_location: row
.request_location_json
.0
.normalized()
.map_err(|_| invalid_location())?,
model_mappings: row.model_mappings_json.0,
usage_retention_days: to_u32(row.usage_retention_days)?,
ops_event_retention_days: to_u32(row.ops_event_retention_days)?,
@@ -472,6 +499,13 @@ fn to_u32(value: i64) -> StoreResult<u32> {
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",
@@ -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<String>,
Option<String>,
i64, i64, i64,
sqlx::types::Json<gateway_core::account::RequestLocation>,
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)?,
@@ -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,
@@ -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)
@@ -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 {
+1 -31
View File
@@ -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<CodexResidency>,
/// 为空时保留客户端的地区、环境日期和时区。
#[serde(default)]
pub location: Option<CodexRequestLocation>,
pub verified_at: DateTime<Utc>,
}
@@ -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<CodexWireProfileConfig> for CodexWireProfile {
arch: value.arch,
terminal: value.terminal,
residency: value.residency,
location: value.location,
verified_at: value.verified_at,
}
}
@@ -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<CodexCredentialQuotaService>,
account_feedback: Arc<AccountFeedbackStats>,
client: CodexBackendClient,
location: Option<CodexRequestLocation>,
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);
@@ -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<CodexResidency>,
/// 可选的地区覆盖;为空时透传,不随制品版本更新而改变。
pub location: Option<CodexRequestLocation>,
/// 版本元组最后一次经制品核验的时间;不表示 TLS 传输已重新核验。
pub verified_at: DateTime<Utc>,
}
@@ -126,7 +126,7 @@ fn adapt_codex_responses_body(
}
}
fn align_structured_location_fields(
pub(crate) fn align_structured_location_fields(
body: &mut Map<String, Value>,
now: DateTime<Utc>,
location: &CodexRequestLocation,
@@ -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()
@@ -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::<CodexRequestLocation>(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()
@@ -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()
@@ -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");
@@ -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()
@@ -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()
@@ -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":"<environment_context><timezone>UTC</timezone></environment_context>"}], "internal_chat_message_metadata_passthrough":{"content_item_kinds":["environments.environment_context"], "create_time":1789293131.822}},
{"role":"user", "content":[{"type":"input_text", "text":"<environment_context><timezone>UTC</timezone></environment_context>"}], "internal_chat_message_metadata_passthrough":{"content_item_kinds":["user.text"]}}
], "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::<RequestLocation>(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!("<environment_context><timezone>{expected}</timezone></environment_context>"))));
assert_eq!(body.pointer("/input/1/content/0/text"), original.pointer("/input/1/content/0/text"));
assert_eq!(body.pointer("/input/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<FrozenAccountScope> {
))
}
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<gateway_core::account::RequestLocation>,
) -> 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": [{
@@ -129,6 +129,22 @@ impl MemoryAccountStore {
.with_scheduling(concurrency_limit, weight);
}
pub(crate) fn set_egress(
&self,
id: &str,
proxy: Option<gateway_core::account::OutboundProxy>,
location: Option<gateway_core::account::RequestLocation>,
) {
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)
}
@@ -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()
@@ -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(),
})
}
@@ -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()
@@ -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()
@@ -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()
+1
View File
@@ -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
@@ -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
);
+8 -6
View File
@@ -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`。升级时将原有自定义值填写到管理端全局设置并开启开关,
并从配置文件删除该项;数据库初始化不会从配置文件自动导入旧值。
## 启动
-3
View File
@@ -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:
+24 -3
View File
@@ -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 排队。
+4
View File
@@ -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 的实际费用,不能因请求重试而清空已产生的费用或未知计费状态。
+2
View File
@@ -26,3 +26,5 @@ export * from './modules/system'
// 使用记录
export * from './modules/usage'
export type { RequestLocation } from './types/request-location'
+4 -2
View File
@@ -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<ProxyMutation>({
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<ProxyMutation>({
url: '/api/admin/proxies/update',
method: 'POST',
+3
View File
@@ -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<string, string>
refreshMarginSeconds: number
refreshConcurrency: number
@@ -0,0 +1,6 @@
export interface RequestLocation {
country: string
region: string
city: string
timezone: string
}
@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { RequestLocation } from '@/api'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseInput from '@/components/base/BaseInput.vue'
defineProps<{ disabled?: boolean }>()
const location = defineModel<RequestLocation>({ required: true })
function update(field: keyof RequestLocation, value: string) {
location.value = { ...location.value, [field]: value }
}
</script>
<template>
<div class="grid gap-4 sm:grid-cols-2">
<BaseFormItem label="国家代码" required>
<BaseInput :model-value="location.country" :disabled="disabled" aria-label="国家代码" placeholder="请输入两位国家代码" maxlength="2" @update:model-value="update('country', $event)" />
</BaseFormItem>
<BaseFormItem label="地区" required>
<BaseInput :model-value="location.region" :disabled="disabled" aria-label="地区" placeholder="请输入地区" maxlength="128" @update:model-value="update('region', $event)" />
</BaseFormItem>
<BaseFormItem label="城市" required>
<BaseInput :model-value="location.city" :disabled="disabled" aria-label="城市" placeholder="请输入城市" maxlength="128" @update:model-value="update('city', $event)" />
</BaseFormItem>
<BaseFormItem label="IANA 时区" required>
<BaseInput :model-value="location.timezone" :disabled="disabled" aria-label="IANA 时区" placeholder="请输入 IANA 时区" @update:model-value="update('timezone', $event)" />
</BaseFormItem>
</div>
</template>
+16
View File
@@ -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 ''
}
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { OutboundProxyRecord } from '@/api'
import type { OutboundProxyRecord, RequestLocation } from '@/api'
import { Eye, EyeOff, Save, Wifi } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
@@ -8,6 +8,8 @@ import BaseForm from '@/components/base/BaseForm/index.vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BaseSwitch from '@/components/base/BaseSwitch.vue'
import RequestLocationFields from '@/components/RequestLocationFields.vue'
const props = defineProps<{
proxy: OutboundProxyRecord | null
@@ -21,6 +23,8 @@ const emit = defineEmits<{
const open = defineModel<boolean>({ required: true })
const name = defineModel<string>('name', { required: true })
const proxyUrl = defineModel<string>('proxyUrl', { required: true })
const customLocation = defineModel<boolean>('customLocation', { required: true })
const location = defineModel<RequestLocation>('location', { required: true })
const showSecret = shallowRef(false)
const busy = computed(() => props.saving || props.testing)
const title = computed(() => props.proxy ? '编辑代理' : '新增代理')
@@ -39,14 +43,14 @@ watch(open, () => {
<BaseFormItem label="代理名称" required>
<BaseInput v-model="name" maxlength="100" :disabled="busy" aria-label="代理名称" placeholder="请输入代理名称" />
</BaseFormItem>
<BaseFormItem label="代理 URL" :required="!proxy" :description="connectionDescription">
<BaseFormItem label="代理地址" :required="!proxy" :description="connectionDescription">
<BaseInput
v-model="proxyUrl"
:type="showSecret ? 'text' : 'password'"
autocomplete="new-password"
:disabled="busy"
aria-label="代理 URL"
placeholder="请输入代理 URL"
aria-label="代理地址"
placeholder="请输入代理地址"
>
<template #suffix>
<BaseIconButton :label="showSecret ? '隐藏代理地址' : '显示代理地址'" :disabled="busy" @click="showSecret = !showSecret">
@@ -56,6 +60,8 @@ watch(open, () => {
</template>
</BaseInput>
</BaseFormItem>
<BaseSwitch v-model="customLocation" label="自定义时区位置" show-label :disabled="busy" />
<RequestLocationFields v-if="customLocation" v-model="location" :disabled="busy" />
<p v-if="proxy?.accountCount && proxyUrl.trim()" class="m-0 text-cp-sm text-cp-warning-text">
将更新 {{ proxy.accountCount }} 个关联账号的出口。
</p>
+27 -7
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { OutboundProxyRecord } from '@/api'
import { LockKeyhole, Pencil, Plus, Search, Trash2, Users, Wifi } from '@lucide/vue'
import { LockKeyhole, MapPin, Pencil, Plus, Search, Trash2, Users, Wifi } from '@lucide/vue'
import { watchDebounced } from '@vueuse/core'
import { computed, onMounted, reactive, ref, shallowRef, watch } from 'vue'
import { createProxy, deleteProxy, getProxies, probeProxy, testProxy, updateProxy } from '@/api'
@@ -17,6 +17,7 @@ import { toast } from '@/components/base/BaseToast'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { usePagedQuery } from '@/composables/usePagedQuery'
import { formatDateTime } from '@/utils/date'
import { normalizeRequestLocation, requestLocationError } from '@/utils/request-location'
import ProxyAccountsModal from './components/ProxyAccountsModal.vue'
import ProxyFormModal from './components/ProxyFormModal.vue'
@@ -28,7 +29,8 @@ const query = usePagedQuery({
const { items: proxies, loading } = query
const pagination = computed(() => ({ currentPage: query.page.value, pageSize: query.pageSize.value, total: query.total.value }))
const columns = defineTableColumns<OutboundProxyRecord>([
{ key: 'identity', label: '代理', kind: 'identity', size: 'xl' },
{ key: 'name', label: '代理名称', kind: 'identity', size: 'lg' },
{ key: 'address', label: '代理地址', kind: 'identity', size: 'xl' },
{ key: 'exitIp', label: '出口 IP', kind: 'custom' },
{ key: 'latency', label: '耗时', kind: 'custom', size: 'sm' },
{ key: 'accounts', label: '关联账号', kind: 'custom', size: 'sm' },
@@ -37,7 +39,7 @@ const columns = defineTableColumns<OutboundProxyRecord>([
])
const showForm = shallowRef(false)
const editing = shallowRef<OutboundProxyRecord | null>(null)
const form = reactive({ name: '', proxyUrl: '' })
const form = reactive({ name: '', proxyUrl: '', customLocation: false, location: { country: '', region: '', city: '', timezone: '' } })
const saveAction = useAsyncAction()
const { loading: saving } = saveAction
const deleteAction = useAsyncAction()
@@ -54,6 +56,8 @@ function openForm(proxy: OutboundProxyRecord | null = null) {
editing.value = proxy
form.name = proxy?.name ?? ''
form.proxyUrl = ''
form.customLocation = proxy?.location != null
form.location = proxy?.location ? { ...proxy.location } : { country: '', region: '', city: '', timezone: '' }
showForm.value = true
}
@@ -106,11 +110,19 @@ async function save() {
toast.warning('请填写代理名称和连接地址')
return
}
const location = form.customLocation
? normalizeRequestLocation(form.location)
: null
const locationError = location ? requestLocationError(location) : ''
if (locationError) {
toast.warning(locationError)
return
}
await saveAction.run(async () => {
// 编辑时留空保留已保存的地址和认证,不能用脱敏地址覆盖原连接。
await (editing.value
? updateProxy({ id: editing.value.id, revision: editing.value.revision, name, proxyUrl: proxyUrl || undefined })
: createProxy({ name, proxyUrl }))
? updateProxy({ id: editing.value.id, revision: editing.value.revision, name, proxyUrl: proxyUrl || undefined, location })
: createProxy({ name, proxyUrl, location }))
showForm.value = false
form.proxyUrl = ''
toast.success('代理已保存')
@@ -184,13 +196,19 @@ onMounted(() => void query.execute())
<template #body>
<div class="flex h-full min-h-0 flex-col">
<BaseTable class="min-h-0 flex-1" :columns="columns" :rows="proxies" :loading="loading" :empty-text="search.trim() ? '没有找到匹配的代理,请尝试其他名称' : '暂无代理,请点击新增代理添加'">
<template #identity="{ row }">
<template #name="{ row }">
<strong class="block truncate text-cp text-cp-text" :title="row.name">{{ row.name }}</strong>
</template>
<template #address="{ row }">
<div class="grid min-w-0 gap-1">
<strong class="truncate text-cp text-cp-text" :title="row.name">{{ row.name }}</strong>
<span class="flex min-w-0 items-center gap-1 text-cp-xs font-emphasis text-cp-text-quaternary">
<LockKeyhole v-if="row.hasAuthentication" class="size-3 shrink-0" aria-label="已保存代理认证" />
<span class="truncate font-mono" :title="row.endpoint">{{ row.endpoint }}</span>
</span>
<span v-if="row.location" class="flex min-w-0 items-center gap-1 text-cp-xs text-cp-text-secondary" :title="`${row.location.country} / ${row.location.region} / ${row.location.city} · ${row.location.timezone}`">
<MapPin class="size-3 shrink-0" aria-hidden="true" />
<span class="truncate">{{ row.location.city }} · {{ row.location.timezone }}</span>
</span>
</div>
</template>
<template #exitIp="{ row }">
@@ -236,6 +254,8 @@ onMounted(() => void query.execute())
v-model="showForm"
v-model:name="form.name"
v-model:proxy-url="form.proxyUrl"
v-model:custom-location="form.customLocation"
v-model:location="form.location"
:proxy="editing"
:saving="saving"
:testing="testingForm"
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { RequestLocation } from '@/api'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseForm from '@/components/base/BaseForm/index.vue'
import BaseSwitch from '@/components/base/BaseSwitch.vue'
import RequestLocationFields from '@/components/RequestLocationFields.vue'
defineProps<{ disabled: boolean }>()
const location = defineModel<RequestLocation>({ required: true })
const enabled = defineModel<boolean>('enabled', { required: true })
</script>
<template>
<BaseCard title="请求时区与位置" description="开启后覆盖 Codex 请求的时区与位置,代理自定义配置优先">
<template #body>
<div>
<BaseSwitch v-model="enabled" label="启用" show-label :disabled="disabled" />
</div>
<BaseForm class="mt-4 max-w-6xl">
<RequestLocationFields v-model="location" :disabled="disabled || !enabled" />
</BaseForm>
</template>
</BaseCard>
</template>
@@ -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<Array<{ requestedModel: string, upstreamModel: string }>>([])
const savedRequestLocation = shallowRef<RequestLocation>()
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<ReturnType<typeof getSettings>>) {
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,
+2
View File
@@ -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"
/>
<RequestLocationCard v-model="form.requestLocation" v-model:enabled="form.requestLocationEnabled" :disabled="loading || saving" />
<AccountAutoFreezeCard
v-model:enabled="form.accountAutoFreezeEnabled"
v-model:threshold="accountAutoFreezeThresholdValue"