mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
co-authored by
Codex
Zyy
parent
499b43043e
commit
bd61e0391a
Generated
+1
@@ -1510,6 +1510,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"chrono-tz",
|
||||
"futures",
|
||||
"futures-timer",
|
||||
"semver",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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`。升级时将原有自定义值填写到管理端全局设置并开启开关,
|
||||
并从配置文件删除该项;数据库初始化不会从配置文件自动导入旧值。
|
||||
|
||||
## 启动
|
||||
|
||||
|
||||
@@ -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
@@ -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 排队。
|
||||
|
||||
@@ -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 的实际费用,不能因请求重试而清空已产生的费用或未知计费状态。
|
||||
|
||||
@@ -26,3 +26,5 @@ export * from './modules/system'
|
||||
|
||||
// 使用记录
|
||||
export * from './modules/usage'
|
||||
|
||||
export type { RequestLocation } from './types/request-location'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user