From 4c7e4e08f6701fef2af5bb0a7a04d8c7d3ef41f2 Mon Sep 17 00:00:00 2001 From: Zyy Date: Sat, 19 Sep 2026 06:41:03 +0800 Subject: [PATCH] feat: refine admin controls and overload protection Refine settings navigation, selective pricing sync, model deletion, and admin interactions. Consolidate unreleased migrations and keep Fast policy scoped to groups. Increase confirmed capacity-rejection feedback without changing ordinary failure recovery. Co-authored-by: Codex --- README.md | 3 - .../crates/gateway-admin/src/model/pricing.rs | 12 +- .../gateway-admin/src/model/settings.rs | 2 - .../crates/gateway-admin/src/ports/store.rs | 2 +- .../gateway-admin/src/use_case/settings.rs | 58 +++- .../gateway-admin/tests/use_case/accounts.rs | 3 +- .../tests/use_case/freeze_recovery.rs | 3 +- .../gateway-admin/tests/use_case/mod.rs | 2 +- .../tests/use_case/observability.rs | 3 +- .../gateway-admin/tests/use_case/settings.rs | 3 +- .../crates/gateway-api/src/admin/settings.rs | 10 +- backend/crates/gateway-api/tests/admin/mod.rs | 35 ++- .../gateway-api/tests/admin/settings.rs | 257 +++++++++++++++-- .../gateway-core/src/account/selection.rs | 22 +- .../gateway-core/src/engine/provider.rs | 14 +- .../crates/gateway-core/src/routing/mod.rs | 5 +- .../gateway-core/src/routing/snapshot.rs | 19 -- .../gateway-core/tests/account/selection.rs | 52 ++++ .../gateway-core/tests/engine/coordinator.rs | 4 +- .../gateway-core/tests/engine/execution.rs | 33 ++- .../gateway-core/tests/routing/snapshot.rs | 13 +- .../crates/gateway-store/src/admin_adapter.rs | 7 +- .../gateway-store/src/postgres/pricing.rs | 39 ++- .../src/postgres/runtime_settings.rs | 14 +- .../gateway-store/src/postgres/snapshot.rs | 6 +- .../gateway-store/tests/postgres/pricing.rs | 186 +++++++++++- .../tests/postgres/runtime_settings.rs | 27 -- .../openai/tests/provider/failure.rs | 47 +++- backend/migrations/.frozen-sha256 | 3 +- ...016_client_profiles_and_model_pricing.sql} | 11 + backend/migrations/0017_model_pricing.sql | 8 - deploy/README.md | 8 +- docs/api.md | 28 +- docs/architecture.md | 4 +- docs/plans/upstream-client-profiles.md | 266 ------------------ frontend/src/api/modules/pricing.ts | 8 +- frontend/src/api/modules/settings.ts | 1 - .../components/AccountGroupCheckboxGrid.vue | 2 +- .../components/base/BaseMarkdown/index.vue | 33 +++ .../components/base/BaseMarkdown/markdown.css | 102 +++++++ .../base/BaseMarkdown}/markdown.ts | 2 +- .../src/components/base/BaseModal/index.vue | 22 +- .../src/components/base/BaseTable/index.vue | 9 +- .../client-profile/ClientProfileEditor.vue | 65 ++--- .../components/SystemUpdateModal/index.vue | 94 +------ frontend/src/router/routes.ts | 10 + .../AccountCreateModal/AccountSetupFields.vue | 2 +- .../AccountCreateModal/presenter.ts | 2 +- .../accounts/components/AccountEditModal.vue | 2 +- .../AccountProfileModal/TokenActivity.vue | 2 +- .../components/AccountProfileModal/index.vue | 4 +- .../AccountQuotaForecastModal/index.vue | 14 +- .../AccountQuotaPanel/ResetCredits.vue | 4 +- .../AccountQuotaSummaryCell/index.vue | 2 +- .../AccountStatusBadge/presenter.ts | 44 +-- .../composables/useAccountResetCredits.ts | 2 +- .../components/HealthTimelinePointPopover.vue | 2 +- .../components/AccountGroupFormModal.vue | 27 +- frontend/src/views/groups/index.vue | 8 +- .../key-usage/components/KeyUsageBudget.vue | 2 +- .../key-usage/components/KeyUsageRecords.vue | 2 +- .../key-usage/components/KeyUsageSkeleton.vue | 2 +- frontend/src/views/key-usage/index.vue | 2 +- .../keys/components/ApiKeyBudgetCell.vue | 4 +- .../components/ApiKeyBudgetResetModal.vue | 77 +++-- .../keys/components/ApiKeyCreateModal.vue | 7 +- .../keys/components/ApiKeyIdentityCell.vue | 8 +- .../views/keys/components/ApiKeyUseModal.vue | 2 +- .../keys/composables/useApiKeyMutations.ts | 2 +- frontend/src/views/keys/index.vue | 4 +- .../proxies/components/ProxyAccountsModal.vue | 2 +- .../proxies/components/ProxyFormModal.vue | 6 +- frontend/src/views/proxies/index.vue | 2 +- .../components/AccountAutoFreezeCard.vue | 12 +- .../settings/components/AdminPasswordCard.vue | 20 +- .../components/ChangePasswordModal.vue | 3 - .../settings/components/FastPolicyCard.vue | 20 -- .../settings/components/ModelAliasesCard.vue | 2 +- .../components/RequestLocationCard.vue | 4 +- .../settings/components/RequestQueueCard.vue | 14 +- .../components/ResponseBodyLimitCard.vue | 6 +- .../components/RuntimeSettingsCard.vue | 12 +- .../components/SettingsAccessSection.vue | 77 +++++ .../settings/components/TokenRefreshCard.vue | 10 +- .../components/backup/BackupScheduleCard.vue | 12 +- .../components/backup/BackupStorageCard.vue | 8 +- .../components/backup/R2GuideModal.vue | 10 +- .../components/ClientCliInstallPanel.vue | 4 +- .../components/ClientDesktopInstallPanel.vue | 2 +- .../ClientDesktopOfflineDownloads.vue | 4 +- .../settings/composables/useBackupSettings.ts | 2 +- .../settings/composables/useSettingsForm.ts | 11 +- frontend/src/views/settings/constants.ts | 4 +- frontend/src/views/settings/index.vue | 200 ++++++------- .../settings/pricing/PricingBatchModal.vue | 23 +- .../views/settings/pricing/PricingEditor.vue | 14 +- .../settings/pricing/PricingSyncModal.vue | 97 +++++-- .../views/settings/pricing/PricingTable.vue | 22 +- .../views/settings/pricing/PricingToolbar.vue | 2 +- .../views/settings/pricing/PricingUnit.vue | 4 +- frontend/src/views/settings/pricing/index.vue | 11 +- frontend/src/views/settings/pricing/model.ts | 2 + .../src/views/settings/pricing/usePricing.ts | 8 +- .../components/ThemeComponentPreview.vue | 4 +- .../components/ThemeComponentTokenPanel.vue | 2 +- .../components/ThemeGlobalTokenPanel.vue | 2 +- .../views/usage/components/OpsErrorPanel.vue | 2 +- .../components/RequestTransportFailure.vue | 2 +- .../usage/components/UsageDiagnosticCard.vue | 2 +- .../usage/components/UsageLatencyCell.vue | 4 +- frontend/src/views/usage/constants.ts | 4 +- .../views/usage/utils/diagnosticsBundle.ts | 12 +- 112 files changed, 1466 insertions(+), 982 deletions(-) rename backend/migrations/{0016_client_request_profiles.sql => 0016_client_profiles_and_model_pricing.sql} (50%) delete mode 100644 backend/migrations/0017_model_pricing.sql delete mode 100644 docs/plans/upstream-client-profiles.md create mode 100644 frontend/src/components/base/BaseMarkdown/index.vue create mode 100644 frontend/src/components/base/BaseMarkdown/markdown.css rename frontend/src/{layout/components/SystemUpdateModal => components/base/BaseMarkdown}/markdown.ts (86%) delete mode 100644 frontend/src/views/settings/components/FastPolicyCard.vue create mode 100644 frontend/src/views/settings/components/SettingsAccessSection.vue diff --git a/README.md b/README.md index f22d95c5a..b7c5a738a 100644 --- a/README.md +++ b/README.md @@ -86,9 +86,6 @@ API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage` 2. 按需建立账号分组,再创建客户端密钥并选择可用分组。**不选分组表示可使用全部账号**。 3. 打开密钥的「使用密钥」,复制客户端配置。 -账号自动冻结默认关闭,可在「系统设置 → 运行参数」中启用。启用后,账号频繁命中上游容量错误时 -会暂停调度并显示为限流中;若开启恢复前探测,冷却到期后需探测成功才恢复。 - ## 客户端接入 **Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,或通过 CCSwitch 导入。 diff --git a/backend/crates/gateway-admin/src/model/pricing.rs b/backend/crates/gateway-admin/src/model/pricing.rs index 69515693c..0101fd528 100644 --- a/backend/crates/gateway-admin/src/model/pricing.rs +++ b/backend/crates/gateway-admin/src/model/pricing.rs @@ -1,7 +1,7 @@ //! 全局模型定价的管理合同。 use gateway_core::metering::{ModelPriceOverride, PricingOverrides}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct PricingCatalog { @@ -25,11 +25,21 @@ pub struct PricingSyncPreview { pub skipped: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SyncPricing { + pub preview: PricingSyncPreview, + pub models: BTreeMap>, +} + +pub type PricingSyncChanges = BTreeMap>>; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum PricingChange { Replace(ModelPriceOverride), Multiplier(u32), Reset, + Delete, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/backend/crates/gateway-admin/src/model/settings.rs b/backend/crates/gateway-admin/src/model/settings.rs index 6739f4a9e..6c79a4340 100644 --- a/backend/crates/gateway-admin/src/model/settings.rs +++ b/backend/crates/gateway-admin/src/model/settings.rs @@ -18,7 +18,6 @@ pub use gateway_core::account::RotationStrategy; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RuntimeSettings { pub openai_client_profile: Option, - pub disable_fast: bool, pub config_revision: Revision, pub request_location_enabled: bool, pub request_location: gateway_core::account::RequestLocation, @@ -51,7 +50,6 @@ pub struct RuntimeSettings { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReplaceRuntimeSettings { pub openai_client_profile: Option, - pub disable_fast: Option, pub request_location_enabled: bool, pub request_location: gateway_core::account::RequestLocation, pub model_mappings: ModelMappings, diff --git a/backend/crates/gateway-admin/src/ports/store.rs b/backend/crates/gateway-admin/src/ports/store.rs index f923aef35..59e4b48e3 100644 --- a/backend/crates/gateway-admin/src/ports/store.rs +++ b/backend/crates/gateway-admin/src/ports/store.rs @@ -420,7 +420,7 @@ pub trait SettingsStore: Send + Sync { async fn load_pricing(&self) -> AdminStoreResult; async fn sync_pricing( &self, - prices: gateway_core::metering::PricingOverrides, + changes: crate::model::pricing::PricingSyncChanges, context: &MutationContext, ) -> AdminStoreResult; async fn update_pricing( diff --git a/backend/crates/gateway-admin/src/use_case/settings.rs b/backend/crates/gateway-admin/src/use_case/settings.rs index 166f18d57..cf64bf2ec 100644 --- a/backend/crates/gateway-admin/src/use_case/settings.rs +++ b/backend/crates/gateway-admin/src/use_case/settings.rs @@ -29,7 +29,7 @@ pub trait SettingsService: Send + Sync { async fn sync_pricing( &self, context: &MutationContext, - preview: crate::model::pricing::PricingSyncPreview, + command: crate::model::pricing::SyncPricing, ) -> Result<(), AdminError>; async fn pricing(&self) -> Result; async fn update_pricing( @@ -103,17 +103,55 @@ impl SettingsService for DefaultSettingsService { async fn sync_pricing( &self, context: &MutationContext, - preview: crate::model::pricing::PricingSyncPreview, + command: crate::model::pricing::SyncPricing, ) -> Result<(), AdminError> { - let current = self.pricing_source.fetch().await?; - if current != preview { + let count = command + .models + .values() + .map(std::collections::BTreeSet::len) + .sum::(); + if count == 0 + || count > 10_000 + || command + .models + .values() + .any(std::collections::BTreeSet::is_empty) + { + return Err(AdminError::invalid("请选择 1 至 10000 个模型")); + } + let mut current = self.pricing_source.fetch().await?; + if current != command.preview { return Err(AdminError::invalid( "models.dev 价目已变化,请重新预览后确认", )); } + let stored = self + .store + .load_pricing() + .await + .map_err(|error| map_store_error(error, "model pricing"))?; + let mut changes = crate::model::pricing::PricingSyncChanges::new(); + for (provider, models) in command.models { + let selected = changes.entry(provider.clone()).or_default(); + for model in models { + let price = current + .prices + .get_mut(&provider) + .and_then(|prices| prices.remove(&model)); + if price.is_none() + && !stored + .synced + .get(&provider) + .is_some_and(|prices| prices.contains_key(&model)) + { + return Err(AdminError::invalid("所选模型不在来源价目中,请重新预览")); + } + selected.insert(model, price); + } + } let revision = self .store - .sync_pricing(current.prices, context) + .sync_pricing(changes, context) .await .map_err(|error| map_store_error(error, "model pricing sync"))?; publish_committed(self.snapshot.as_ref(), revision).await @@ -153,6 +191,16 @@ impl SettingsService for DefaultSettingsService { { return Err(AdminError::invalid("Provider、模型 ID 或批量数量不合法")); } + if command.change == PricingChange::Delete + && command.models.iter().any(|model| { + catalog + .defaults + .get(&command.provider) + .is_some_and(|models| models.contains_key(model)) + }) + { + return Err(AdminError::invalid("内置价目不能删除,仅支持人工覆盖")); + } let defaults = gateway_core::metering::merge_pricing(catalog.defaults, &catalog.synced); let defaults = defaults.get(&command.provider); match &command.change { diff --git a/backend/crates/gateway-admin/tests/use_case/accounts.rs b/backend/crates/gateway-admin/tests/use_case/accounts.rs index f2508562d..ae35b7cad 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts.rs @@ -976,7 +976,7 @@ impl SettingsStore for StaticSettingsStore { } async fn sync_pricing( &self, - _: gateway_core::metering::PricingOverrides, + _: gateway_admin::model::pricing::PricingSyncChanges, _: &MutationContext, ) -> AdminStoreResult { panic!("unexpected pricing sync") @@ -991,7 +991,6 @@ impl SettingsStore for StaticSettingsStore { async fn load_runtime_settings(&self) -> AdminStoreResult { Ok(RuntimeSettings { openai_client_profile: None, - disable_fast: false, request_location_enabled: false, request_location: Default::default(), config_revision: revision(1), diff --git a/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs b/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs index ac1ea4375..8a1bbc7eb 100644 --- a/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs +++ b/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs @@ -26,7 +26,6 @@ use super::accounts::{FakeAccountStore, FakeProviderAdmin, account_record, event fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> RuntimeSettings { RuntimeSettings { openai_client_profile: None, - disable_fast: false, request_location_enabled: false, request_location: Default::default(), config_revision: revision(1), @@ -67,7 +66,7 @@ impl SettingsStore for FreezeSettingsStore { } async fn sync_pricing( &self, - _: gateway_core::metering::PricingOverrides, + _: gateway_admin::model::pricing::PricingSyncChanges, _: &MutationContext, ) -> AdminStoreResult { panic!("unexpected pricing sync") diff --git a/backend/crates/gateway-admin/tests/use_case/mod.rs b/backend/crates/gateway-admin/tests/use_case/mod.rs index b330844a4..890a24f23 100644 --- a/backend/crates/gateway-admin/tests/use_case/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/mod.rs @@ -711,7 +711,7 @@ impl SettingsStore for UnavailableStore { } async fn sync_pricing( &self, - _: gateway_core::metering::PricingOverrides, + _: gateway_admin::model::pricing::PricingSyncChanges, _: &MutationContext, ) -> AdminStoreResult { panic!("unexpected pricing sync") diff --git a/backend/crates/gateway-admin/tests/use_case/observability.rs b/backend/crates/gateway-admin/tests/use_case/observability.rs index 0d74e70e0..33cbbb26b 100644 --- a/backend/crates/gateway-admin/tests/use_case/observability.rs +++ b/backend/crates/gateway-admin/tests/use_case/observability.rs @@ -758,7 +758,7 @@ impl SettingsStore for FixtureSettingsStore { } async fn sync_pricing( &self, - _: gateway_core::metering::PricingOverrides, + _: gateway_admin::model::pricing::PricingSyncChanges, _: &MutationContext, ) -> AdminStoreResult { panic!("unexpected pricing sync") @@ -773,7 +773,6 @@ impl SettingsStore for FixtureSettingsStore { async fn load_runtime_settings(&self) -> AdminStoreResult { Ok(RuntimeSettings { openai_client_profile: None, - disable_fast: false, request_location_enabled: false, request_location: Default::default(), config_revision: Revision::new(1).expect("revision"), diff --git a/backend/crates/gateway-admin/tests/use_case/settings.rs b/backend/crates/gateway-admin/tests/use_case/settings.rs index a5b599acf..c950f46e6 100644 --- a/backend/crates/gateway-admin/tests/use_case/settings.rs +++ b/backend/crates/gateway-admin/tests/use_case/settings.rs @@ -20,7 +20,7 @@ impl SettingsStore for UnusedSettingsStore { } async fn sync_pricing( &self, - _: gateway_core::metering::PricingOverrides, + _: gateway_admin::model::pricing::PricingSyncChanges, _: &MutationContext, ) -> AdminStoreResult { panic!("unexpected pricing sync") @@ -79,7 +79,6 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() { }, ReplaceRuntimeSettings { openai_client_profile: None, - disable_fast: None, request_location_enabled: false, request_location: Default::default(), model_mappings: Default::default(), diff --git a/backend/crates/gateway-api/src/admin/settings.rs b/backend/crates/gateway-api/src/admin/settings.rs index 1928de900..a0060919f 100644 --- a/backend/crates/gateway-api/src/admin/settings.rs +++ b/backend/crates/gateway-api/src/admin/settings.rs @@ -35,7 +35,6 @@ pub type ModelMappings = BTreeMap; #[serde(rename_all = "camelCase")] pub struct RuntimeSettingsView { pub openai_client_profile: Option>, - pub disable_fast: bool, pub request_location_enabled: bool, pub request_location: gateway_core::account::RequestLocation, pub model_mappings: ModelMappings, @@ -69,7 +68,6 @@ pub struct RuntimeSettingsView { pub struct UpdateRuntimeSettingsRequest { #[serde(default, deserialize_with = "deserialize_profile_update")] pub openai_client_profile: Option>, - pub disable_fast: Option, pub request_location_enabled: bool, pub request_location: gateway_core::account::RequestLocation, pub model_mappings: ModelMappings, @@ -183,7 +181,6 @@ impl UpdateRuntimeSettingsRequest { openai_client_profile: self .openai_client_profile .map(gateway_core::account::OpaqueProviderData::new), - disable_fast: self.disable_fast, request_location_enabled: self.request_location_enabled, request_location: self .request_location @@ -228,7 +225,6 @@ impl From for RuntimeSettingsView { openai_client_profile: settings .openai_client_profile .map(gateway_core::account::OpaqueProviderData::into_inner), - disable_fast: settings.disable_fast, request_location_enabled: settings.request_location_enabled, request_location: settings.request_location, model_mappings: wire_model_mappings(settings.model_mappings), @@ -429,6 +425,7 @@ enum PricingChangeRequest { multiplier_bps: u32, }, Reset {}, + Delete {}, } async fn pricing( @@ -475,7 +472,7 @@ where async fn sync_pricing( auth: AdminAuth, State(state): State, - AdminJson(preview): AdminJson, + AdminJson(command): AdminJson, ) -> Result where S: SessionState + Send + Sync, @@ -483,7 +480,7 @@ where state .admin_services() .settings() - .sync_pricing(&auth.context().mutation_context(), preview) + .sync_pricing(&auth.context().mutation_context(), command) .await .map_err(map_service_error)?; Ok(AdminResponse::new( @@ -507,6 +504,7 @@ where PricingChange::Multiplier(multiplier_bps) } PricingChangeRequest::Reset {} => PricingChange::Reset, + PricingChangeRequest::Delete {} => PricingChange::Delete, }; state .admin_services() diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs index 6ae3102f0..7f11b8493 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -446,11 +446,21 @@ impl SettingsStore for MemorySettingsStore { } async fn sync_pricing( &self, - prices: gateway_core::metering::PricingOverrides, + changes: gateway_admin::model::pricing::PricingSyncChanges, _: &MutationContext, ) -> AdminStoreResult { let mut pricing = self.pricing.lock().expect("pricing"); - pricing.synced = prices; + for (provider, models) in changes { + let stored = pricing.synced.entry(provider).or_default(); + for (model, price) in models { + if let Some(price) = price { + stored.insert(model, price); + } else { + stored.remove(&model); + } + } + } + pricing.synced.retain(|_, models| !models.is_empty()); pricing.synced_at = Some(Utc::now()); let mut settings = self.settings.lock().expect("settings"); settings.config_revision = next_revision(settings.config_revision); @@ -463,12 +473,20 @@ impl SettingsStore for MemorySettingsStore { ) -> AdminStoreResult { use gateway_admin::model::pricing::PricingChange; let mut pricing = self.pricing.lock().expect("pricing"); - let models = pricing.overrides.entry(command.provider).or_default(); + let gateway_admin::model::pricing::StoredPricing { + overrides, synced, .. + } = &mut *pricing; + let models = overrides.entry(command.provider.clone()).or_default(); + let source = synced.entry(command.provider).or_default(); for model in command.models { match &command.change { PricingChange::Reset => { models.remove(&model); } + PricingChange::Delete => { + models.remove(&model); + source.remove(&model); + } PricingChange::Replace(p) => { models.insert(model, p.clone()); } @@ -503,7 +521,6 @@ impl SettingsStore for MemorySettingsStore { let mut settings = self.settings.lock().expect("settings"); let updated = RuntimeSettings { openai_client_profile: None, - disable_fast: command.disable_fast.unwrap_or(settings.disable_fast), request_location_enabled: command.request_location_enabled, request_location: command.request_location, config_revision: next_revision(settings.config_revision), @@ -1243,6 +1260,15 @@ impl UnusedProvider { #[async_trait] impl ProviderAdmin for UnusedProvider { + fn pricing_catalog(&self) -> gateway_admin::model::pricing::ProviderPricingCatalog { + serde_json::from_value(serde_json::json!({ + "builtin-model": { + "multiplierBps": 10000, + "bands": {"standard": {"input": "1", "output": "2", "cacheRead": "0", "cacheWrite": "1"}} + } + })).unwrap() + } + fn provider_kind(&self) -> &ProviderKind { &self.kind } @@ -1409,7 +1435,6 @@ fn test_runtime_settings() -> RuntimeSettings { ]); RuntimeSettings { openai_client_profile: None, - disable_fast: false, request_location_enabled: false, request_location: Default::default(), config_revision: Revision::new(7).expect("revision"), diff --git a/backend/crates/gateway-api/tests/admin/settings.rs b/backend/crates/gateway-api/tests/admin/settings.rs index ab31f2021..16fe7a94d 100644 --- a/backend/crates/gateway-api/tests/admin/settings.rs +++ b/backend/crates/gateway-api/tests/admin/settings.rs @@ -37,7 +37,6 @@ async fn response_json(response: axum::response::Response) -> Value { fn update_body() -> Value { json!({ - "disableFast": false, "requestLocationEnabled": false, "requestLocation": {"country":"US", "region":"Ohio", "city":"Piketon", "timezone":"America/New_York"}, "modelMappings": { @@ -104,7 +103,6 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() { let settings = RuntimeSettings { openai_client_profile: None, - disable_fast: false, request_location_enabled: false, request_location: Default::default(), config_revision: Revision::new(7).expect("revision"), @@ -150,7 +148,6 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() { value, json!({ "openaiClientProfile": null, - "disableFast": false, "requestLocationEnabled": false, "requestLocation": {"country":"US", "region":"Ohio", "city":"Piketon", "timezone":"America/New_York"}, "modelMappings": { @@ -205,7 +202,6 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() { .collect(); let settings = RuntimeSettings { openai_client_profile: None, - disable_fast: false, request_location_enabled: false, request_location: Default::default(), config_revision: Revision::new(7).expect("revision"), @@ -610,17 +606,14 @@ fn decompression_setting_should_reject_invalid_values() { } #[tokio::test] -async fn disable_fast_settings_updates_preserve_omitted_values() { +async fn settings_reject_removed_global_fast_policy() { let fixture = AdminTestFixture::new().await; fixture.auth.insert_session("valid-session"); let app = app(fixture.state()); - for (value, expected) in [(Some(true), true), (None, true), (Some(false), false)] { + let initial = fixture.settings.settings.lock().unwrap().clone(); + for value in [json!(true), json!(false), Value::Null] { let mut body = update_body(); - if let Some(value) = value { - body["disableFast"] = json!(value); - } else { - body.as_object_mut().unwrap().remove("disableFast"); - } + body["disableFast"] = value; let response = app .clone() .oneshot(request( @@ -630,17 +623,19 @@ async fn disable_fast_settings_updates_preserve_omitted_values() { )) .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let response = app - .clone() - .oneshot(request(Method::GET, "/api/admin/settings", None)) - .await - .unwrap(); - assert_eq!( - response_json(response).await["data"]["disableFast"], - expected - ); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(*fixture.settings.settings.lock().unwrap(), initial); } + let response = app + .oneshot(request(Method::GET, "/api/admin/settings", None)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert!( + response_json(response).await["data"] + .get("disableFast") + .is_none() + ); } #[test] @@ -685,7 +680,7 @@ async fn pricing_routes_keep_manual_overrides_during_sync_and_reset_to_source() .oneshot(request( Method::POST, "/api/admin/settings/pricing/sync", - Some(approved), + Some(json!({"preview": approved, "models": {"openai": ["gpt-5.4"]}})), )) .await .unwrap(); @@ -745,6 +740,88 @@ async fn pricing_routes_keep_manual_overrides_during_sync_and_reset_to_source() assert!(result["data"]["synced"]["openai"]["gpt-5.4"].is_object()); } +#[tokio::test] +async fn pricing_delete_rejects_builtin_models_even_with_overrides_without_partial_writes() { + for overridden in [false, true] { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let app = app(fixture.state()); + { + let mut stored = fixture.settings.pricing.lock().unwrap(); + stored.synced = serde_json::from_value(json!({ + "openai": {"builtin-model": custom_pricing(), "custom-model": custom_pricing()} + })) + .unwrap(); + if overridden { + stored.overrides = stored.synced.clone(); + } + } + let before = fixture.settings.pricing.lock().unwrap().clone(); + let revision = fixture.settings.settings.lock().unwrap().config_revision; + for models in [ + json!(["builtin-model"]), + json!(["custom-model", "builtin-model"]), + ] { + let response = app + .clone() + .oneshot(request( + Method::POST, + "/api/admin/settings/pricing/update", + Some( + json!({"provider":"openai", "models":models, "change":{"action":"delete"}}), + ), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(*fixture.settings.pricing.lock().unwrap(), before); + assert_eq!( + fixture.settings.settings.lock().unwrap().config_revision, + revision + ); + } + } +} + +#[tokio::test] +async fn pricing_delete_removes_selected_nonbuiltin_models_from_both_layers() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let app = app(fixture.state()); + { + let mut stored = fixture.settings.pricing.lock().unwrap(); + stored.synced = serde_json::from_value(json!({ + "openai": {"shared":custom_pricing(), "synced-only":custom_pricing(), "untouched":custom_pricing()}, + "xai": {"shared":custom_pricing()} + })).unwrap(); + stored.overrides = serde_json::from_value(json!({ + "openai": {"shared":custom_pricing(), "custom-only":custom_pricing(), "untouched":custom_pricing()}, + "xai": {"shared":custom_pricing()} + })).unwrap(); + } + for _ in 0..2 { + let response = app.clone().oneshot(request( + Method::POST, + "/api/admin/settings/pricing/update", + Some(json!({ + "provider":"openai", "models":["shared", "synced-only", "custom-only"], "change":{"action":"delete"} + })), + )).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + let result = response_json( + app.oneshot(request(Method::GET, "/api/admin/settings/pricing", None)) + .await + .unwrap(), + ) + .await; + let expected = + json!({"openai":{"untouched":custom_pricing()},"xai":{"shared":custom_pricing()}}); + assert_eq!(result["data"]["overrides"], expected); + assert_eq!(result["data"]["synced"], expected); + assert!(result["data"]["defaults"]["openai"]["builtin-model"].is_object()); +} + #[tokio::test] async fn pricing_rejects_invalid_edits_and_tampered_sync_without_writes() { let fixture = AdminTestFixture::new().await; @@ -787,7 +864,7 @@ async fn pricing_rejects_invalid_edits_and_tampered_sync_without_writes() { .oneshot(request( Method::POST, "/api/admin/settings/pricing/sync", - Some(json!({"prices":{},"skipped":[]})), + Some(json!({"preview":{"prices":{},"skipped":[]},"models":{"openai":["gpt-5.4"]}})), )) .await .unwrap(); @@ -802,6 +879,138 @@ async fn pricing_rejects_invalid_edits_and_tampered_sync_without_writes() { assert_eq!(result["data"]["synced"], json!({})); } +#[tokio::test] +async fn pricing_sync_only_updates_selected_provider_models() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let old = custom_pricing(); + { + let mut pricing = fixture.settings.pricing.lock().unwrap(); + pricing.synced = serde_json::from_value(json!({ + "openai": {"gpt-5.4": old, "untouched": old}, + "xai": {"gpt-5.4": old} + })) + .unwrap(); + pricing.overrides = serde_json::from_value(json!({"openai": {"gpt-5.4": old}})).unwrap(); + } + let app = app(fixture.state()); + let preview = response_json( + app.clone() + .oneshot(request( + Method::POST, + "/api/admin/settings/pricing/sync/preview", + None, + )) + .await + .unwrap(), + ) + .await; + let response = app + .oneshot(request( + Method::POST, + "/api/admin/settings/pricing/sync", + Some(json!({"preview": preview["data"], "models": {"openai": ["gpt-5.4"]}})), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let pricing = fixture.settings.pricing.lock().unwrap(); + assert_eq!( + serde_json::to_value(&pricing.synced).unwrap(), + json!({ + "openai": {"gpt-5.4": preview["data"]["prices"]["openai"]["gpt-5.4"], "untouched": old}, + "xai": {"gpt-5.4": old} + }) + ); + assert_eq!( + serde_json::to_value(&pricing.overrides).unwrap(), + json!({"openai": {"gpt-5.4": old}}) + ); +} + +#[tokio::test] +async fn pricing_sync_removes_only_selected_retired_source_prices() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + { + let mut pricing = fixture.settings.pricing.lock().unwrap(); + pricing.synced = serde_json::from_value(json!({ + "openai": {"retired": custom_pricing(), "untouched": custom_pricing()} + })) + .unwrap(); + } + let app = app(fixture.state()); + let preview = response_json( + app.clone() + .oneshot(request( + Method::POST, + "/api/admin/settings/pricing/sync/preview", + None, + )) + .await + .unwrap(), + ) + .await; + let response = app + .oneshot(request( + Method::POST, + "/api/admin/settings/pricing/sync", + Some(json!({"preview": preview["data"], "models": {"openai": ["retired"]}})), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let pricing = fixture.settings.pricing.lock().unwrap(); + assert_eq!( + serde_json::to_value(&pricing.synced).unwrap(), + json!({ + "openai": {"untouched": custom_pricing()} + }) + ); +} + +#[tokio::test] +async fn pricing_sync_rejects_empty_unknown_or_oversized_selections_without_writes() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let app = app(fixture.state()); + let preview = response_json( + app.clone() + .oneshot(request( + Method::POST, + "/api/admin/settings/pricing/sync/preview", + None, + )) + .await + .unwrap(), + ) + .await; + for models in [ + json!({}), + json!({"openai": []}), + json!({"unknown": ["gpt-5.4"]}), + json!({"openai": ["gpt-5.4", "missing"]}), + json!({"openai": ["gpt-5.4"], "xai": []}), + json!({"openai": (0..10_001).map(|index| format!("model-{index}")).collect::>()}), + ] { + let response = app + .clone() + .oneshot(request( + Method::POST, + "/api/admin/settings/pricing/sync", + Some(json!({"preview": preview["data"], "models": models})), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + let pricing = fixture.settings.pricing.lock().unwrap(); + assert_eq!( + *pricing, + gateway_admin::model::pricing::StoredPricing::default() + ); +} + #[tokio::test] async fn pricing_endpoints_require_administrator_authentication() { let fixture = AdminTestFixture::new().await; @@ -821,7 +1030,7 @@ async fn pricing_endpoints_require_administrator_authentication() { ( Method::POST, "/api/admin/settings/pricing/sync", - Some(json!({"prices":{},"skipped":[]})), + Some(json!({"preview":{"prices":{},"skipped":[]},"models":{"openai":["gpt-5.4"]}})), ), ] { assert_eq!( diff --git a/backend/crates/gateway-core/src/account/selection.rs b/backend/crates/gateway-core/src/account/selection.rs index 77a13a09e..884945161 100644 --- a/backend/crates/gateway-core/src/account/selection.rs +++ b/backend/crates/gateway-core/src/account/selection.rs @@ -112,6 +112,7 @@ pub struct AccountRuntimeSignals { } const ACCOUNT_FEEDBACK_EWMA_ALPHA: f64 = 0.2; +const ACCOUNT_CAPACITY_FAILURE_EWMA_ALPHA: f64 = 0.4; const ACCOUNT_FAILURE_RATE_HALF_LIFE: Duration = Duration::from_secs(15 * 60); const EMPTY_FEEDBACK_SAMPLE: u64 = f64::NAN.to_bits(); @@ -120,6 +121,7 @@ const EMPTY_FEEDBACK_SAMPLE: u64 = f64::NAN.to_bits(); pub enum AccountAttemptFeedback { Succeeded { first_output_ms: Option }, Failed { first_output_ms: Option }, + CapacityRejected { first_output_ms: Option }, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -150,13 +152,12 @@ impl DecayingAccountFailureRate { * 0.5_f64.powf(elapsed.as_secs_f64() / ACCOUNT_FAILURE_RATE_HALF_LIFE.as_secs_f64()) } - fn report_at(&mut self, sample: f64, now: Instant) { + fn report_at(&mut self, sample: f64, alpha: f64, now: Instant) { let now = self .updated_at .map_or(now, |updated_at| updated_at.max(now)); let decayed = self.value_at(now); - self.value = - ACCOUNT_FEEDBACK_EWMA_ALPHA * sample + (1.0 - ACCOUNT_FEEDBACK_EWMA_ALPHA) * decayed; + self.value = alpha * sample + (1.0 - alpha) * decayed; self.updated_at = Some(now); } } @@ -173,14 +174,21 @@ impl Default for AccountFeedback { impl AccountFeedback { fn report_at(&self, feedback: AccountAttemptFeedback, now: Instant) { - let (failure, first_output_ms) = match feedback { - AccountAttemptFeedback::Succeeded { first_output_ms } => (0.0, first_output_ms), - AccountAttemptFeedback::Failed { first_output_ms } => (1.0, first_output_ms), + let (failure, alpha, first_output_ms) = match feedback { + AccountAttemptFeedback::Succeeded { first_output_ms } => { + (0.0, ACCOUNT_FEEDBACK_EWMA_ALPHA, first_output_ms) + } + AccountAttemptFeedback::Failed { first_output_ms } => { + (1.0, ACCOUNT_FEEDBACK_EWMA_ALPHA, first_output_ms) + } + AccountAttemptFeedback::CapacityRejected { first_output_ms } => { + (1.0, ACCOUNT_CAPACITY_FAILURE_EWMA_ALPHA, first_output_ms) + } }; self.failure_rate .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) - .report_at(failure, now); + .report_at(failure, alpha, now); if let Some(first_output_ms) = first_output_ms.filter(|value| *value > 0) { update_feedback_ewma(&self.first_output_ms, first_output_ms as f64); } diff --git a/backend/crates/gateway-core/src/engine/provider.rs b/backend/crates/gateway-core/src/engine/provider.rs index d1100be35..5bb50ea02 100644 --- a/backend/crates/gateway-core/src/engine/provider.rs +++ b/backend/crates/gateway-core/src/engine/provider.rs @@ -264,13 +264,17 @@ impl ProviderStreamAccountFeedback { { return; } - self.stats.report( - &self.provider_kind, - &self.account_id, + let feedback = if error.kind() == ProviderErrorKind::UpstreamCapacityUnavailable { + AccountAttemptFeedback::CapacityRejected { + first_output_ms: self.first_output_ms, + } + } else { AccountAttemptFeedback::Failed { first_output_ms: self.first_output_ms, - }, - ); + } + }; + self.stats + .report(&self.provider_kind, &self.account_id, feedback); self.reported = true; } } diff --git a/backend/crates/gateway-core/src/routing/mod.rs b/backend/crates/gateway-core/src/routing/mod.rs index b32869cef..8325c6a53 100644 --- a/backend/crates/gateway-core/src/routing/mod.rs +++ b/backend/crates/gateway-core/src/routing/mod.rs @@ -537,7 +537,6 @@ impl ProviderCandidate { #[derive(Debug, Clone)] pub struct RoutingPlan { pricing: Arc, - disable_fast: bool, request_location: Option, config_revision: ConfigRevision, account_selection_policy: AccountSelectionPolicy, @@ -554,8 +553,8 @@ impl RoutingPlan { } #[must_use] - pub const fn disable_fast(&self) -> bool { - self.disable_fast + pub fn disable_fast(&self) -> bool { + self.account_scope.disable_fast() } /// 本次请求冻结的全局位置,重试时沿用同一份配置。 diff --git a/backend/crates/gateway-core/src/routing/snapshot.rs b/backend/crates/gateway-core/src/routing/snapshot.rs index e208fcaf8..037b15a54 100644 --- a/backend/crates/gateway-core/src/routing/snapshot.rs +++ b/backend/crates/gateway-core/src/routing/snapshot.rs @@ -30,7 +30,6 @@ const MAXIMUM_CATALOG_STABILITY_ATTEMPTS: usize = 4; pub struct SnapshotSettingsFacts { pricing: Arc, request_profiles: BTreeMap, - disable_fast: bool, request_location_enabled: bool, request_location: crate::account::RequestLocation, max_concurrent_per_account: u32, @@ -61,12 +60,6 @@ impl SnapshotSettingsFacts { self } - #[must_use] - pub const fn with_disable_fast(mut self, disable_fast: bool) -> Self { - self.disable_fast = disable_fast; - self - } - #[must_use] pub const fn with_responses_max_decompressed_body_bytes(mut self, bytes: u64) -> Self { self.responses_max_decompressed_body_bytes = bytes; @@ -107,7 +100,6 @@ impl SnapshotSettingsFacts { min_codex_cli_version: Option, ) -> Self { Self { - disable_fast: false, request_profiles: BTreeMap::new(), pricing: Arc::default(), request_location_enabled: false, @@ -565,7 +557,6 @@ async fn compile_runtime_snapshot( .map(|snapshot| { snapshot .with_pricing(facts.settings.pricing) - .with_disable_fast(facts.settings.disable_fast) .with_request_location(request_location) .with_responses_max_decompressed_body_bytes(decompressed_body_limit) .with_client_queue_policy(client_queue_policy) @@ -580,7 +571,6 @@ async fn compile_runtime_snapshot( #[derive(Debug, Clone)] pub struct RuntimeSnapshot { pricing: Arc, - disable_fast: bool, responses_max_decompressed_body_bytes: std::num::NonZeroUsize, request_location: Option, revision: ConfigRevision, @@ -605,12 +595,6 @@ impl RuntimeSnapshot { self } - #[must_use] - pub const fn with_disable_fast(mut self, disable_fast: bool) -> Self { - self.disable_fast = disable_fast; - self - } - #[must_use] pub const fn responses_max_decompressed_body_bytes(&self) -> usize { self.responses_max_decompressed_body_bytes.get() @@ -716,7 +700,6 @@ impl RuntimeSnapshot { responses_max_decompressed_body_bytes: std::num::NonZeroUsize::new(64 * 1024 * 1024) .expect("positive default limit"), pricing: Arc::default(), - disable_fast: false, request_location: None, revision, account_selection_policy, @@ -1029,7 +1012,6 @@ impl RuntimeSnapshot { Ok(RoutingPlan { config_revision: self.revision, pricing: Arc::clone(&self.pricing), - disable_fast: self.disable_fast || account_scope.disable_fast(), request_location: self.request_location.clone(), account_selection_policy: self.account_selection_policy, operation: operation.kind(), @@ -1073,7 +1055,6 @@ impl RuntimeSnapshot { Ok(RoutingPlan { config_revision: self.revision, pricing: Arc::clone(&self.pricing), - disable_fast: self.disable_fast || account_scope.disable_fast(), request_location: self.request_location.clone(), account_selection_policy: self.account_selection_policy, operation: operation.kind(), diff --git a/backend/crates/gateway-core/tests/account/selection.rs b/backend/crates/gateway-core/tests/account/selection.rs index 96f6d1f7b..085cb8393 100644 --- a/backend/crates/gateway-core/tests/account/selection.rs +++ b/backend/crates/gateway-core/tests/account/selection.rs @@ -34,6 +34,58 @@ fn report_failure( ); } +#[test] +fn capacity_rejections_should_raise_failure_rate_faster_than_regular_failures() { + let (feedback, provider, account) = feedback_subject(); + let observed_at = Instant::now(); + let mut failure_rates = Vec::new(); + for _ in 0..2 { + feedback.report_at( + &provider, + &account, + AccountAttemptFeedback::CapacityRejected { + first_output_ms: None, + }, + observed_at, + ); + failure_rates.push( + feedback + .scheduling_signals_at(&provider, &account, observed_at) + .0, + ); + } + + assert_eq!(failure_rates, [Some(4_000), Some(6_400)]); +} + +#[test] +fn capacity_failure_rate_should_keep_time_decay_and_success_recovery() { + let (feedback, provider, account) = feedback_subject(); + let observed_at = Instant::now(); + feedback.report_at( + &provider, + &account, + AccountAttemptFeedback::CapacityRejected { + first_output_ms: Some(100), + }, + observed_at, + ); + let recovered_at = observed_at + FAILURE_RATE_HALF_LIFE; + feedback.report_at( + &provider, + &account, + AccountAttemptFeedback::Succeeded { + first_output_ms: Some(200), + }, + recovered_at, + ); + + assert_eq!( + feedback.scheduling_signals_at(&provider, &account, recovered_at), + (Some(1_600), Some(120)), + ); +} + #[test] fn account_failure_rate_should_halve_after_one_half_life() { let (feedback, provider, account) = feedback_subject(); diff --git a/backend/crates/gateway-core/tests/engine/coordinator.rs b/backend/crates/gateway-core/tests/engine/coordinator.rs index 3e08dd4d0..eee0bc952 100644 --- a/backend/crates/gateway-core/tests/engine/coordinator.rs +++ b/backend/crates/gateway-core/tests/engine/coordinator.rs @@ -677,7 +677,6 @@ fn plan_with_profiles( Vec::new(), ) .expect("snapshot") - .with_disable_fast(disable_fast) .with_request_location(Some(request_location)) .with_account_directory(Arc::clone(&directory)); snapshot @@ -686,6 +685,7 @@ fn plan_with_profiles( operation, Arc::new( FrozenAccountScope::new(directory, ClientRoutingScope::all_accounts()) + .with_disable_fast(disable_fast) .with_request_profiles(profiles), ), &RoutingContext { @@ -4570,7 +4570,7 @@ fn deadline_before_first_event_records_no_provider_circuit_failure() { } #[test] -fn global_location_and_fast_policy_reach_every_account_retry() { +fn global_location_and_group_fast_policy_reach_every_account_retry() { let operation = generate_operation(); let location = gateway_core::account::RequestLocation { timezone: "Asia/Tokyo".parse().unwrap(), diff --git a/backend/crates/gateway-core/tests/engine/execution.rs b/backend/crates/gateway-core/tests/engine/execution.rs index 9ca99a051..3d5dc4b13 100644 --- a/backend/crates/gateway-core/tests/engine/execution.rs +++ b/backend/crates/gateway-core/tests/engine/execution.rs @@ -178,7 +178,7 @@ fn reused_client_uses_updated_limits_for_each_execution() { requests_per_minute: 1, }; for (revision, limits) in [(2, limited), (3, RateLimits::unlimited())] { - snapshots.publish(start_snapshot_with_policy(revision, true, limits)); + snapshots.publish(start_snapshot_with_policy(revision, true, limits, false)); let mut next = request(&service, ClientTransport::WebSocket); next.client = client.clone(); let started = block_on(service.start(next)).expect("new execution"); @@ -212,6 +212,7 @@ fn reused_client_cannot_start_after_key_disable_or_snapshot_suspension() { 2, false, RateLimits::unlimited(), + false, )); } let result = block_on(service.start(next)); @@ -1893,10 +1894,15 @@ fn client_snapshot() -> RuntimeSnapshot { } fn start_snapshot() -> RuntimeSnapshot { - start_snapshot_with_policy(1, true, RateLimits::unlimited()) + start_snapshot_with_policy(1, true, RateLimits::unlimited(), false) } -fn start_snapshot_with_policy(revision: u64, enabled: bool, limits: RateLimits) -> RuntimeSnapshot { +fn start_snapshot_with_policy( + revision: u64, + enabled: bool, + limits: RateLimits, + disable_fast: bool, +) -> RuntimeSnapshot { let provider = ProviderKind::new("openai").expect("provider kind"); let capabilities = ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), Some(16_000)); @@ -1916,7 +1922,12 @@ fn start_snapshot_with_policy(revision: u64, enabled: bool, limits: RateLimits) vec![ClientPolicy::new( ClientApiKeyId::new("key_start_test").expect("client API key ID"), PlaintextClientApiKey::new("sk_start_test").expect("plaintext client API key"), - account_scope(&provider, "acct_start"), + Arc::new( + account_scope(&provider, "acct_start") + .as_ref() + .clone() + .with_disable_fast(disable_fast), + ), enabled, limits, )], @@ -2013,6 +2024,7 @@ fn account_wait_inherits_the_budget_spent_during_client_admission() { max_concurrency: 1, requests_per_minute: 0, }, + false, ) .with_client_queue_policy(gateway_core::concurrency::ConcurrencyQueuePolicy { max_waiting: 1, @@ -2123,6 +2135,7 @@ fn queue_service( max_concurrency, requests_per_minute: 0, }, + false, ) .with_client_queue_policy(gateway_core::concurrency::ConcurrencyQueuePolicy { max_waiting, @@ -2249,7 +2262,7 @@ fn cancelling_pending_admission_cleans_up_a_slot_acquired_before_the_reply() { } #[test] -fn reused_websocket_client_gets_fast_policy_from_each_new_request_snapshot() { +fn reused_websocket_client_gets_group_fast_policy_from_each_new_request_snapshot() { let snapshots = RuntimeSnapshotHandle::new(start_snapshot()); let admissions = Arc::new(Admissions::default()); let provider = Arc::new(ChargedProvider::default()); @@ -2264,10 +2277,12 @@ fn reused_websocket_client_gets_fast_policy_from_each_new_request_snapshot() { ); let client = service.authenticate("sk_start_test").unwrap(); for (revision, disable_fast) in [(2, true), (3, false)] { - snapshots.publish( - start_snapshot_with_policy(revision, true, RateLimits::unlimited()) - .with_disable_fast(disable_fast), - ); + snapshots.publish(start_snapshot_with_policy( + revision, + true, + RateLimits::unlimited(), + disable_fast, + )); let mut next = request(&service, ClientTransport::WebSocket); next.client = client.clone(); let mut started = block_on(service.start(next)).unwrap(); diff --git a/backend/crates/gateway-core/tests/routing/snapshot.rs b/backend/crates/gateway-core/tests/routing/snapshot.rs index e5fbe83a6..809bc7796 100644 --- a/backend/crates/gateway-core/tests/routing/snapshot.rs +++ b/backend/crates/gateway-core/tests/routing/snapshot.rs @@ -494,10 +494,10 @@ fn decompression_setting_should_validate_and_remain_frozen_across_publication() } #[test] -fn disable_fast_uses_bound_groups_and_global_policy_without_changing_account_scope() { +fn disable_fast_uses_only_bound_groups_without_changing_account_scope() { use gateway_core::account::ProviderAccountId; use gateway_core::routing::AccountGroupId; - for global in [false, true] { + for disable_fast in [false, true] { for group_enabled in [false, true] { for bound in [false, true] { let group_id = AccountGroupId::new("grp_00000000000000000000000000000001").unwrap(); @@ -507,8 +507,7 @@ fn disable_fast_uses_bound_groups_and_global_policy_without_changing_account_sco let facts = SnapshotFacts::new( revision(1), revision(1), - SnapshotSettingsFacts::new(3, 0, "smart", BTreeMap::new(), None, None) - .with_disable_fast(global), + SnapshotSettingsFacts::new(3, 0, "smart", BTreeMap::new(), None, None), vec![SnapshotClientPolicyFacts::new( ClientApiKeyId::new("key_fast_policy").unwrap(), PlaintextClientApiKey::new("sk_fast_policy").unwrap(), @@ -525,7 +524,7 @@ fn disable_fast_uses_bound_groups_and_global_policy_without_changing_account_sco "Restricted".to_owned(), group_enabled, ) - .with_disable_fast(true), + .with_disable_fast(disable_fast), SnapshotAccountGroupFacts::new( open_group_id.clone(), "Open".to_owned(), @@ -566,8 +565,8 @@ fn disable_fast_uses_bound_groups_and_global_policy_without_changing_account_sco .unwrap(); assert_eq!( plan.disable_fast(), - global || bound, - "global={global}, enabled={group_enabled}, bound={bound}" + disable_fast && bound, + "disable_fast={disable_fast}, enabled={group_enabled}, bound={bound}" ); } } diff --git a/backend/crates/gateway-store/src/admin_adapter.rs b/backend/crates/gateway-store/src/admin_adapter.rs index 58a24d99c..719c476eb 100644 --- a/backend/crates/gateway-store/src/admin_adapter.rs +++ b/backend/crates/gateway-store/src/admin_adapter.rs @@ -24,7 +24,7 @@ impl SettingsStore for AdminSettingsStoreAdapter { async fn sync_pricing( &self, - prices: gateway_core::metering::PricingOverrides, + changes: gateway_admin::model::pricing::PricingSyncChanges, context: &MutationContext, ) -> AdminStoreResult { let audit = mutation_audit( @@ -36,7 +36,7 @@ impl SettingsStore for AdminSettingsStoreAdapter { ); let revision = self .control_plane - .sync_pricing(prices, audit) + .sync_pricing(changes, audit) .await .map_err(|error| admin_store_error("model pricing sync", error))?; admin_revision(revision) @@ -91,7 +91,6 @@ 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, - disable_fast: command.disable_fast, request_location_enabled: command.request_location_enabled, request_location: command.request_location, request_interval_ms: command.request_interval_ms, @@ -123,7 +122,6 @@ impl SettingsStore for AdminSettingsStoreAdapter { "1", vec![ "provider_request_profiles_json".to_owned(), - "disable_fast".to_owned(), "request_location_enabled".to_owned(), "request_location_json".to_owned(), "model_mappings_json".to_owned(), @@ -235,7 +233,6 @@ pub(crate) fn admin_runtime_settings( Ok(AdminRuntimeSettings { openai_client_profile: settings.openai_client_profile, config_revision: admin_revision(settings.config_revision)?, - disable_fast: settings.disable_fast, request_location_enabled: settings.request_location_enabled, request_location: settings.request_location, model_mappings, diff --git a/backend/crates/gateway-store/src/postgres/pricing.rs b/backend/crates/gateway-store/src/postgres/pricing.rs index 9241725cd..7d438c28b 100644 --- a/backend/crates/gateway-store/src/postgres/pricing.rs +++ b/backend/crates/gateway-store/src/postgres/pricing.rs @@ -1,6 +1,6 @@ //! 价格覆盖与请求费用明细的持久化。 -use gateway_admin::model::pricing::{PricingChange, UpdatePricing}; +use gateway_admin::model::pricing::{PricingChange, PricingSyncChanges, UpdatePricing}; use gateway_core::metering::{ModelPriceOverride, PricingOverrides}; use sqlx::types::Json; @@ -146,16 +146,32 @@ impl PgControlPlaneRepository { pub(crate) async fn sync_pricing( &self, - prices: PricingOverrides, + changes: PricingSyncChanges, audit: AdminAuditEvent, ) -> StoreResult { - validate_pricing(&prices)?; let mut transaction = self .pool .begin() .await .map_err(|_| postgres_unavailable("begin pricing sync"))?; - // 同步只替换来源层;不写人工覆盖。运行设置行锁统一串行化配置修订。 + let Json(mut prices) = sqlx::query_scalar::<_, Json>( + "select pricing_synced_json from runtime_settings where id = 1 for update", + ) + .fetch_one(&mut *transaction) + .await + .map_err(|_| postgres_unavailable("lock synced model pricing"))?; + for (provider, models) in changes { + let stored = prices.entry(provider).or_default(); + for (model, price) in models { + if let Some(price) = price { + stored.insert(model, price); + } else { + stored.remove(&model); + } + } + } + prices.retain(|_, models| !models.is_empty()); + validate_pricing(&prices)?; sqlx::query("update runtime_settings set pricing_synced_json = $1, pricing_synced_at = now() where id = 1") .bind(Json(prices)).execute(&mut *transaction).await.map_err(|_| postgres_unavailable("sync model pricing"))?; let revision = bump_config_revision_in_transaction(&mut transaction).await?; @@ -178,18 +194,23 @@ impl PgControlPlaneRepository { .await .map_err(|_| postgres_unavailable("begin model pricing update"))?; // 锁定当前配置再更新选中项,避免批量操作覆盖其他管理员已提交的模型。 - let Json(mut pricing) = sqlx::query_scalar::<_, Json>( - "select pricing_overrides_json from runtime_settings where id = 1 for update", + let (Json(mut pricing), Json(mut synced)) = sqlx::query_as::<_, (Json, Json)>( + "select pricing_overrides_json, pricing_synced_json from runtime_settings where id = 1 for update", ) .fetch_one(&mut *transaction) .await .map_err(|_| postgres_unavailable("lock model pricing"))?; - let models = pricing.entry(command.provider).or_default(); + let models = pricing.entry(command.provider.clone()).or_default(); + let source = synced.entry(command.provider).or_default(); for model in command.models { match &command.change { PricingChange::Reset => { models.remove(&model); } + PricingChange::Delete => { + models.remove(&model); + source.remove(&model); + } PricingChange::Replace(value) => { models.insert(model, value.clone()); } @@ -205,9 +226,11 @@ impl PgControlPlaneRepository { } } pricing.retain(|_, models| !models.is_empty()); + synced.retain(|_, models| !models.is_empty()); validate_pricing(&pricing)?; - sqlx::query("update runtime_settings set pricing_overrides_json = $1 where id = 1") + sqlx::query("update runtime_settings set pricing_overrides_json = $1, pricing_synced_json = $2 where id = 1") .bind(Json(pricing)) + .bind(Json(synced)) .execute(&mut *transaction) .await .map_err(|_| postgres_unavailable("update model pricing"))?; diff --git a/backend/crates/gateway-store/src/postgres/runtime_settings.rs b/backend/crates/gateway-store/src/postgres/runtime_settings.rs index 15e01315d..de705ca9d 100644 --- a/backend/crates/gateway-store/src/postgres/runtime_settings.rs +++ b/backend/crates/gateway-store/src/postgres/runtime_settings.rs @@ -20,7 +20,6 @@ use crate::{Revision, StoreError, StoreResult, postgres_unavailable}; #[derive(Clone, PartialEq, Eq)] pub struct RuntimeSettings { pub openai_client_profile: Option, - pub disable_fast: bool, pub config_revision: Revision, pub admin_api_key: Option, pub refresh_margin_seconds: u64, @@ -66,7 +65,6 @@ impl fmt::Debug for RuntimeSettings { &self.max_concurrent_per_account, ) .field("request_interval_ms", &self.request_interval_ms) - .field("disable_fast", &self.disable_fast) .field("rotation_strategy", &self.rotation_strategy) .field("request_location_enabled", &self.request_location_enabled) .field("request_location", &self.request_location) @@ -112,7 +110,6 @@ impl fmt::Debug for RuntimeSettings { #[derive(Clone)] pub struct RuntimeSettingsUpdate { pub openai_client_profile: Option, - pub disable_fast: Option, pub admin_api_key: Option, pub refresh_margin_seconds: u64, pub refresh_concurrency: u32, @@ -148,7 +145,6 @@ impl fmt::Debug for RuntimeSettingsUpdate { "admin_api_key", &self.admin_api_key.as_ref().map(|_| "[REDACTED]"), ) - .field("disable_fast", &self.disable_fast) .field("rotation_strategy", &self.rotation_strategy) .field("request_location_enabled", &self.request_location_enabled) .field("request_location", &self.request_location) @@ -235,7 +231,7 @@ impl RuntimeSettingsRepository for PgRuntimeSettingsRepository { pub(crate) async fn load_runtime_settings_from_pool(pool: &PgPool) -> StoreResult { let row = sqlx::query_as::<_, RuntimeSettingsRow>( - "select provider_request_profiles_json, config_revision, admin_api_key, refresh_margin_seconds, request_location_json, request_location_enabled, disable_fast, + "select provider_request_profiles_json, 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, @@ -321,7 +317,7 @@ pub(crate) async fn load_runtime_settings_in_transaction( transaction: &mut Transaction<'_, Postgres>, ) -> StoreResult { let row = sqlx::query_as::<_, RuntimeSettingsRow>( - "select provider_request_profiles_json, config_revision, admin_api_key, refresh_margin_seconds, request_location_json, request_location_enabled, disable_fast, + "select provider_request_profiles_json, 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, @@ -377,8 +373,7 @@ pub(crate) async fn update_runtime_settings_in_transaction( request_location_json = $23, request_location_enabled = $24, responses_max_decompressed_body_bytes = $25, - disable_fast = coalesce($26, disable_fast), - provider_request_profiles_json = case when $27::jsonb is null then provider_request_profiles_json else jsonb_set(provider_request_profiles_json, '{openai}', $27) end, + provider_request_profiles_json = case when $26::jsonb is null then provider_request_profiles_json else jsonb_set(provider_request_profiles_json, '{openai}', $26) end, updated_at = now() where id = 1 returning config_revision", @@ -420,7 +415,6 @@ pub(crate) async fn update_runtime_settings_in_transaction( i64::try_from(update.responses_max_decompressed_body_bytes) .map_err(|_| invalid_numeric())?, ) - .bind(update.disable_fast) .bind(update.openai_client_profile.as_ref().map(|profile| sqlx::types::Json(profile.expose_to_provider()))) .fetch_optional(&mut **transaction) .await @@ -474,7 +468,6 @@ struct RuntimeSettingsRow { provider_request_profiles_json: sqlx::types::Json< std::collections::BTreeMap>, >, - disable_fast: bool, config_revision: i64, admin_api_key: Option, refresh_margin_seconds: i64, @@ -518,7 +511,6 @@ fn runtime_settings_from_row(mut row: RuntimeSettingsRow) -> StoreResult, - pub disable_fast: bool, pub request_location_enabled: bool, pub request_location: gateway_core::account::RequestLocation, pub refresh_margin_seconds: u64, @@ -163,7 +162,6 @@ impl SnapshotStorePort for PgRuntimeSnapshotRepository { .with_responses_max_decompressed_body_bytes( data.settings.responses_max_decompressed_body_bytes, ) - .with_disable_fast(data.settings.disable_fast) .with_request_profiles(data.settings.request_profiles) .with_pricing(data.settings.pricing) .with_request_location( @@ -266,7 +264,6 @@ struct SnapshotSettingsRow { request_location_json: sqlx::types::Json, request_location_enabled: bool, responses_max_decompressed_body_bytes: i64, - disable_fast: bool, provider_request_profiles_json: sqlx::types::Json>>, } @@ -275,7 +272,7 @@ async fn load_settings( transaction: &mut Transaction<'_, Postgres>, ) -> StoreResult<(Revision, SnapshotRuntimeSettings)> { let row = sqlx::query_as::<_, SnapshotSettingsRow>( - "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, request_location_json, request_location_enabled, responses_max_decompressed_body_bytes, disable_fast, provider_request_profiles_json, pricing_overrides_json, pricing_synced_json from runtime_settings where id = 1", + "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, request_location_json, request_location_enabled, responses_max_decompressed_body_bytes, provider_request_profiles_json, pricing_overrides_json, pricing_synced_json from runtime_settings where id = 1", ) .fetch_optional(&mut **transaction) .await @@ -296,7 +293,6 @@ async fn load_settings( ) }, request_profiles: decode_request_profiles(row.provider_request_profiles_json.0)?, - disable_fast: row.disable_fast, responses_max_decompressed_body_bytes: to_u64( row.responses_max_decompressed_body_bytes, )?, diff --git a/backend/crates/gateway-store/tests/postgres/pricing.rs b/backend/crates/gateway-store/tests/postgres/pricing.rs index d51c99130..54e91665e 100644 --- a/backend/crates/gateway-store/tests/postgres/pricing.rs +++ b/backend/crates/gateway-store/tests/postgres/pricing.rs @@ -8,14 +8,9 @@ use serde_json::json; use super::TestDatabase; -#[tokio::test] -async fn concurrent_price_edits_preserve_other_models_and_sync_preserves_manual_prices() { - let Some(redis_url) = crate::support::test_env("CPR_TEST_REDIS_URL") else { - return; - }; - let Some(database) = TestDatabase::create("pricing").await else { - return; - }; +async fn pricing_store(name: &str) -> Option<(TestDatabase, gateway_store::StoreBundle)> { + let redis_url = crate::support::test_env("CPR_TEST_REDIS_URL")?; + let database = TestDatabase::create(name).await?; let mut database_url = url::Url::parse( &crate::support::test_env("CPR_TEST_DATABASE_URL").expect("test database URL"), ) @@ -36,6 +31,14 @@ async fn concurrent_price_edits_preserve_other_models_and_sync_preserves_manual_ let runtime = tempfile::tempdir().unwrap(); config.resolve_and_validate(runtime.path()).unwrap(); let bundle = gateway_store::initialize(config).await.unwrap(); + Some((database, bundle)) +} + +#[tokio::test] +async fn concurrent_price_edits_preserve_other_models_and_sync_preserves_manual_prices() { + let Some((database, bundle)) = pricing_store("pricing").await else { + return; + }; let settings = bundle.admin_ports().settings(); let context = MutationContext { actor: MutationActor::System, @@ -76,7 +79,21 @@ async fn concurrent_price_edits_preserve_other_models_and_sync_preserves_manual_ .unwrap() .input = "9".to_owned().try_into().unwrap(); settings - .sync_pricing(source.clone(), &context) + .sync_pricing( + source + .iter() + .map(|(provider, models)| { + ( + provider.clone(), + models + .iter() + .map(|(model, price)| (model.clone(), Some(price.clone()))) + .collect(), + ) + }) + .collect(), + &context, + ) .await .unwrap(); let stored = settings.load_pricing().await.unwrap(); @@ -123,3 +140,154 @@ async fn concurrent_price_edits_preserve_other_models_and_sync_preserves_manual_ drop(bundle); database.close().await; } + +#[tokio::test] +async fn deleting_pricing_removes_both_layers_and_preserves_other_models_and_frozen_snapshots() { + let Some((database, bundle)) = pricing_store("pricing_delete").await else { + return; + }; + let settings = bundle.admin_ports().settings(); + let context = MutationContext { + actor: MutationActor::System, + request_id: "pricing-delete-test".to_owned(), + }; + let value = json!({ + "multiplierBps":10000, + "bands":{"standard":{"input":"2","output":"10","cacheRead":"0","cacheWrite":"1"}} + }); + settings + .sync_pricing( + serde_json::from_value(json!({ + "openai":{"shared":value,"synced-only":value,"untouched":value}, + "xai":{"shared":value} + })) + .unwrap(), + &context, + ) + .await + .unwrap(); + settings + .update_pricing( + UpdatePricing { + provider: "openai".to_owned(), + models: vec![ + "shared".to_owned(), + "custom-only".to_owned(), + "untouched".to_owned(), + ], + change: PricingChange::Replace(serde_json::from_value(value.clone()).unwrap()), + }, + &context, + ) + .await + .unwrap(); + let repository = PgRuntimeSnapshotRepository::new(database.pool.clone()); + let frozen = repository.load_runtime_snapshot().await.unwrap(); + let synced_at = settings.load_pricing().await.unwrap().synced_at; + settings + .update_pricing( + UpdatePricing { + provider: "openai".to_owned(), + models: vec![ + "shared".to_owned(), + "synced-only".to_owned(), + "custom-only".to_owned(), + ], + change: PricingChange::Delete, + }, + &context, + ) + .await + .unwrap(); + let stored = settings.load_pricing().await.unwrap(); + assert_eq!( + serde_json::to_value(&stored.overrides).unwrap(), + json!({"openai":{"untouched":value}}) + ); + assert_eq!( + serde_json::to_value(&stored.synced).unwrap(), + json!({"openai":{"untouched":value},"xai":{"shared":value}}) + ); + assert_eq!(stored.synced_at, synced_at); + let current = repository.load_runtime_snapshot().await.unwrap(); + assert_eq!(current.settings.pricing["openai"].len(), 1); + assert!(current.settings.pricing["xai"].contains_key("shared")); + assert_eq!(frozen.settings.pricing["openai"].len(), 4); + let audits: i64 = sqlx::query_scalar( + "select count(*) from admin_audit_events where action = 'pricing.update'", + ) + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(audits, 2); + drop(settings); + drop(bundle); + database.close().await; +} + +#[tokio::test] +async fn concurrent_pricing_syncs_merge_selected_changes_and_remove_only_selected_models() { + let Some((database, bundle)) = pricing_store("pricing_selection").await else { + return; + }; + let settings = bundle.admin_ports().settings(); + let context = MutationContext { + actor: MutationActor::System, + request_id: "pricing-selection-test".to_owned(), + }; + let price = json!({ + "multiplierBps":10000, + "bands":{"standard":{"input":"2","output":"10","cacheRead":"0","cacheWrite":"1"}} + }); + let patch = |value| { + serde_json::from_value::(value).unwrap() + }; + let (first, second) = tokio::join!( + settings.sync_pricing(patch(json!({"openai": {"model-a": price}})), &context), + settings.sync_pricing( + patch(json!({"openai": {"model-b": price}, "xai": {"model-a": price}})), + &context + ), + ); + assert_ne!(first.unwrap(), second.unwrap()); + let stored = settings.load_pricing().await.unwrap(); + assert_eq!( + serde_json::to_value(&stored.synced).unwrap(), + json!({ + "openai": {"model-a": price, "model-b": price}, "xai": {"model-a": price} + }) + ); + settings + .sync_pricing(patch(json!({"openai": {"model-a": null}})), &context) + .await + .unwrap(); + let stored = settings.load_pricing().await.unwrap(); + assert_eq!( + serde_json::to_value(&stored.synced).unwrap(), + json!({ + "openai": {"model-b": price}, "xai": {"model-a": price} + }) + ); + let before = stored; + assert!( + settings + .sync_pricing( + patch(json!({ + "openai": {"model-b": null}, "invalid": {"model-c": price} + })), + &context + ) + .await + .is_err() + ); + assert_eq!(settings.load_pricing().await.unwrap(), before); + let audits: i64 = + sqlx::query_scalar("select count(*) from admin_audit_events where action = 'pricing.sync'") + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(audits, 3); + drop(settings); + drop(bundle); + database.close().await; +} diff --git a/backend/crates/gateway-store/tests/postgres/runtime_settings.rs b/backend/crates/gateway-store/tests/postgres/runtime_settings.rs index 043549089..1c1686bd9 100644 --- a/backend/crates/gateway-store/tests/postgres/runtime_settings.rs +++ b/backend/crates/gateway-store/tests/postgres/runtime_settings.rs @@ -10,7 +10,6 @@ use super::TestDatabase; fn settings_with_margin(refresh_margin_seconds: u64) -> RuntimeSettingsUpdate { RuntimeSettingsUpdate { openai_client_profile: None, - disable_fast: None, request_location_enabled: false, request_location: Default::default(), admin_api_key: None, @@ -432,32 +431,6 @@ async fn decompression_setting_should_persist_and_reach_snapshot_facts() { database.close().await; } -#[tokio::test] -async fn disable_fast_persists_and_omitted_updates_preserve_the_restriction() { - let Some(database) = TestDatabase::create("disable_fast_settings").await else { - return; - }; - let repository = PgRuntimeSettingsRepository::new(database.pool.clone()); - let initial = repository.load_runtime_settings().await.unwrap(); - assert!(!initial.disable_fast); - for (value, expected) in [(Some(true), true), (None, true), (Some(false), false)] { - let mut update = settings_with_margin(1_800); - update.disable_fast = value; - let revision = repository.update_runtime_settings(update).await.unwrap(); - let reloaded = repository.load_runtime_settings().await.unwrap(); - assert_eq!(reloaded.disable_fast, expected); - assert_eq!(reloaded.config_revision, revision); - use gateway_store::postgres::{PgRuntimeSnapshotRepository, RuntimeSnapshotRepository}; - let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone()) - .load_runtime_snapshot() - .await - .unwrap(); - assert_eq!(snapshot.settings.disable_fast, expected); - assert_eq!(snapshot.config_revision, revision); - } - database.close().await; -} - #[tokio::test] async fn request_profile_initialization_is_idempotent_and_old_updates_preserve_it() { use gateway_core::{ diff --git a/backend/crates/providers/openai/tests/provider/failure.rs b/backend/crates/providers/openai/tests/provider/failure.rs index cd8aea2c6..75b0691f5 100644 --- a/backend/crates/providers/openai/tests/provider/failure.rs +++ b/backend/crates/providers/openai/tests/provider/failure.rs @@ -210,12 +210,47 @@ fn openai_feedback_should_penalize_capacity_rejections_even_with_transient_retry deliver_error_with_openai_feedback(&feedback, &provider, &account, error); - assert!( - feedback - .scheduling_signals(&provider, &account) - .0 - .is_some_and(|failure_rate| failure_rate > 0), - "capacity rejection did not affect the account score: {code}" + assert_eq!( + feedback.scheduling_signals(&provider, &account).0, + Some(4_000), + "capacity rejection should receive the stronger penalty: {code}" + ); + } +} + +#[test] +fn openai_feedback_should_keep_regular_server_error_penalty() { + let feedback = Arc::new(AccountFeedbackStats::default()); + let provider = ProviderKind::new("openai").expect("provider"); + let account = ProviderAccountId::new("acct_server_error").expect("account"); + + deliver_error_with_openai_feedback( + &feedback, + &provider, + &account, + sent_error(ProviderErrorKind::Unavailable, Some("server_error")), + ); + + assert_eq!( + feedback.scheduling_signals(&provider, &account).0, + Some(2_000) + ); +} + +#[test] +fn openai_feedback_should_ignore_unconfirmed_capacity_rejections() { + for send_state in [UpstreamSendState::NotSent, UpstreamSendState::Ambiguous] { + let feedback = Arc::new(AccountFeedbackStats::default()); + let provider = ProviderKind::new("openai").expect("provider"); + let account = ProviderAccountId::new("acct_unconfirmed").expect("account"); + let error = ProviderError::new(ProviderErrorKind::UpstreamCapacityUnavailable, send_state) + .with_upstream_code(OpaqueUpstreamValue::new("server_is_overloaded")); + + deliver_error_with_openai_feedback(&feedback, &provider, &account, error); + + assert_eq!( + feedback.scheduling_signals(&provider, &account), + (None, None) ); } } diff --git a/backend/migrations/.frozen-sha256 b/backend/migrations/.frozen-sha256 index a8d771aa5..b9df0bc09 100644 --- a/backend/migrations/.frozen-sha256 +++ b/backend/migrations/.frozen-sha256 @@ -13,5 +13,4 @@ b20e425c0b062b72d35fdebe294e023eb17ceb44ac93083ff3d48400715bf430 0012_upstream_ 6e23fceef1096b50c1af3584cb1687eded2ad37e31e65391397ec984bf78c7e3 0013_responses_decompression_limit.sql 12a540be31d65c4925cbe63dab42ea2d4ec2864fecf71a6dd4ee942e71726857 0014_disable_fast.sql 9bd37bd314b6864816c43cb4fd1869fb415920d4f10f766976e344334a1f60fc 0015_proxy_dual_stack_exit.sql -3eb917aab407a497355b21db0faf7974aac757801473b0a13b47b136598b371f 0016_client_request_profiles.sql -3569e370689caac3f03e76539bc7df4b22304988f428cb7f8911aba170c59167 0017_model_pricing.sql +a1e23b8535ed41de27861833cd2d5ee7498a9bb0e523156ba3f011f9c6b3966b 0016_client_profiles_and_model_pricing.sql diff --git a/backend/migrations/0016_client_request_profiles.sql b/backend/migrations/0016_client_profiles_and_model_pricing.sql similarity index 50% rename from backend/migrations/0016_client_request_profiles.sql rename to backend/migrations/0016_client_profiles_and_model_pricing.sql index c7d48bf41..7a75f157a 100644 --- a/backend/migrations/0016_client_request_profiles.sql +++ b/backend/migrations/0016_client_profiles_and_model_pricing.sql @@ -9,3 +9,14 @@ alter table client_api_keys add column provider_request_profiles_json jsonb not null default '{}'::jsonb, add constraint client_api_keys_request_profiles_object check (jsonb_typeof(provider_request_profiles_json) = 'object'); + +-- 覆盖配置与请求历史明细分离,改价不重算已发生的费用。 +alter table runtime_settings add column pricing_overrides_json jsonb not null default '{}' + check (jsonb_typeof(pricing_overrides_json) = 'object'); +alter table runtime_settings add column pricing_synced_json jsonb not null default '{}' + check (jsonb_typeof(pricing_synced_json) = 'object'); +alter table runtime_settings add column pricing_synced_at timestamptz; +alter table model_requests add column billing_snapshot_json jsonb + check (billing_snapshot_json is null or jsonb_typeof(billing_snapshot_json) = 'object'); + +alter table runtime_settings drop column disable_fast; diff --git a/backend/migrations/0017_model_pricing.sql b/backend/migrations/0017_model_pricing.sql deleted file mode 100644 index 77602ef45..000000000 --- a/backend/migrations/0017_model_pricing.sql +++ /dev/null @@ -1,8 +0,0 @@ --- 覆盖配置与请求历史明细分离,改价不重算已发生的费用。 -alter table runtime_settings add column pricing_overrides_json jsonb not null default '{}' - check (jsonb_typeof(pricing_overrides_json) = 'object'); -alter table runtime_settings add column pricing_synced_json jsonb not null default '{}' - check (jsonb_typeof(pricing_synced_json) = 'object'); -alter table runtime_settings add column pricing_synced_at timestamptz; -alter table model_requests add column billing_snapshot_json jsonb - check (billing_snapshot_json is null or jsonb_typeof(billing_snapshot_json) = 'object'); diff --git a/deploy/README.md b/deploy/README.md index d91ead0ae..3da36413c 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -64,7 +64,7 @@ Linux 上应用容器以 `10001:10001` 运行。上述命令将应用数据和 配置设为 `0640`,均由当前用户持有、容器组 `10001` 访问。 `config.yaml` 通过 Compose `configs` 只读挂载,普通 Compose 保留宿主机文件的 UID/GID 和 mode。 -OpenAI 上游身份在管理端「系统设置 → 上游身份」配置,Key 可选择独立身份覆盖通用值。 +OpenAI 上游身份在管理端「系统设置 → 上游配置 → 客户端身份」配置,Key 可选择独立身份覆盖通用值。 首次默认 macOS Desktop 自动最新,支持范围与字段见 [客户端身份合同](../docs/api.md#openai-上游客户端身份)。 旧 `openai.wire_profile` 在数据库尚无选择时一次性导入:默认版本采用自动更新;版本元组、平台或架构 与默认值不同则保留为固定版本。导入后从管理端修改,重启不会用 YAML 覆盖数据库。 @@ -72,7 +72,7 @@ OpenAI 上游身份在管理端「系统设置 → 上游身份」配置,Key OpenAI 上游地址、WebSocket 池、额度刷新和 OAuth 使用 Provider 默认值;xAI 仍在 YAML 配置启动画像。 后台发布检查只更新可重建的版本资料,不回写用户选择或 `config.yaml`;失败继续使用同组合的有效版本。 -全局请求位置在管理端「系统设置 → 运行设置 → 请求时区与位置」配置,默认关闭,保留客户端原有位置和时区。 +全局请求位置在管理端「系统设置 → 上游配置 → 请求位置覆盖」配置,默认关闭,保留客户端原有位置和时区。 开启后使用自定义位置,关闭时保留已保存的自定义值。「代理管理」设置的自定义位置优先于全局设置, 全局关闭时代理自定义仍生效。保存后新请求生效,无需重启;关闭代理自定义后恢复全局继承。 该设置统一 OpenAI Responses 的 Web Search 位置与带来源标记的环境日期、时区,不修改 epoch 时间戳、 @@ -342,12 +342,10 @@ OpenAI 主动额度重置卡及其消费结果由上游持有,不写入 Postgr ## 密码语义 - `admin.default_password` 只在首次创建管理员时使用。 -- 已有管理员在「系统设置 → 管理员密码」修改登录密码,需要验证当前密码;修改后所有管理员会话失效,使用新密码重新登录。 +- 已有管理员在「系统设置 → 安全与访问 → 管理员密码」修改登录密码,需要验证当前密码;修改后所有管理员会话失效,使用新密码重新登录。 - PostgreSQL 官方镜像只在空数据目录初始化时使用 `database.password`。 - Redis 在每次容器创建时使用 `redis.password`。 -管理员会话绑定密码哈希指纹;从尚未包含此字段的版本升级后,管理员需要重新登录一次,密钥身份会话不受影响。 - 已有 PostgreSQL 数据目录后,直接修改 `database.password` 不会修改数据库用户密码,只会导致 应用无法连接。轮换时必须先在 PostgreSQL 中修改用户密码,再同步更新 `config.yaml`。Redis 密码变更后需要用新配置重新创建 Redis 和应用容器,不需要删除 Redis 数据目录。 diff --git a/docs/api.md b/docs/api.md index 881c12e24..b559fc837 100644 --- a/docs/api.md +++ b/docs/api.md @@ -267,7 +267,8 @@ OpenAI 明确返回 `server_is_overloaded`、`slow_down` 或模型容量不足 尚未交付输出的前提下,先做最多 3 次同账号指数退避,再通过现有调度换号。默认间隔从 500ms 开始, 上游 `Retry-After` 参与退避计算,单次等待不超过 8 秒;重试同时受请求总尝试次数和截止时间约束。 `server_is_overloaded`、`slow_down` 等可计分的结构化错误按已发送的失败尝试计入 Smart 账号 -健康分。失败率使用账号级平滑与时间衰减,影响后续普通选路,已有可用账号的 +健康分。已确认容量拒绝的平滑权重为 0.4,其他可计分失败与成功样本保持 0.2。 +失败率使用账号级平滑与时间衰减,影响后续普通选路,已有可用账号的 会话亲和仍优先。容量不足不触发 Provider 全局熔断,也不作为账号额度耗尽;启用账号自动冻结时, 达到容量失败阈值会另外写入临时冷却。 客户端错误兼容由 API 编码出口统一处理:最终交付的 `server_is_overloaded`、`slow_down` 错误码 @@ -857,7 +858,13 @@ PostgreSQL 或 Redis。 分组是 Provider-neutral 的账号集合;一个组可包含任意 Provider 账号,一个账号也可属于多个组。 分组详情和列表返回 `disableFast`,创建时省略默认为 `false`,更新时省略或 `null` 保留现值。 Client Key 绑定的任一分组开启此限制(包括已禁用分组)时,该 Key 的 OpenAI Responses 请求关闭 Fast; -未绑定分组的 Key 只受全局限制,不按最终所选账号的分组判断。 +未绑定分组的 Key 不限制 Fast,不按最终所选账号的分组判断。 + +关闭 Fast 只将顶层 `service_tier` 的 `priority`(含 `fast` 别名)改为显式 `default`,继续处理请求; +不改变 `flex`、`ultrafast`、缺失值、默认档、嵌套字段或其他 Provider。 +HTTP 和每个 WebSocket `response.create` 均使用请求开始时的分组策略,同一请求重试保持该策略; +HTTP 请求头及新建 WS 的握手提示按当时的最终出站档位构造;复用 WS 时不重发握手头, +每个 `response.create` 仍独立应用档位策略,请求档位统计与本地费用估算使用各帧的最终出站档位。 | 方法 | 路由 | 主要 query/body | 说明 | | --- | --- | --- | --- | @@ -971,7 +978,6 @@ HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly ```text openaiClientProfile -disableFast requestLocationEnabled requestLocation modelMappings @@ -998,13 +1004,6 @@ accountAutoFreezeProbeModel accountAutoFreezeAdaptiveConcurrency ``` -`disableFast` 默认 `false`,更新时省略或 `null` 保留现值。全局开启时,所有 Key 的 OpenAI Responses 请求关闭 Fast; -全局关闭时仍应用 Key 绑定分组的限制。关闭 Fast 只将顶层 `service_tier` 的 `priority`(含 `fast` 别名) -改为显式 `default`,继续处理请求;不改变 `flex`、`ultrafast`、缺失值、默认档、嵌套字段或其他 Provider。 -HTTP 和每个 WebSocket `response.create` 均使用请求开始时的配置,同一请求重试保持该配置; -HTTP 请求头及新建 WS 的握手提示按当时的最终出站档位构造;复用 WS 时不重发握手头, -每个 `response.create` 仍独立应用档位策略,请求档位统计与本地费用估算使用各帧的最终出站档位。 - `requestLocationEnabled` 是必填布尔值,默认 `false`:关闭时不覆盖客户端原有位置和时区;开启时使用已保存的 `requestLocation`。关闭不会清空自定义值,代理自定义位置仍优先。 `requestLocation` 是必填的完整对象 `{ country, region, city, timezone }`,不接受 `null`;初始保存值为 @@ -1038,7 +1037,7 @@ HTTP 请求头及新建 WS 的握手提示按当时的最终出站档位构造 | `GET` | `/api/admin/settings/pricing` | 返回 `{ defaults, synced, overrides, syncedAt }` | | `POST` | `/api/admin/settings/pricing/update` | `{ provider, models, change }`,成功返回 `{ saved: true }` | | `POST` | `/api/admin/settings/pricing/sync/preview` | 无 body;返回 `{ prices, skipped }`,不写入配置 | -| `POST` | `/api/admin/settings/pricing/sync` | 原样提交确认的 `{ prices, skipped }`;成功返回 `{ saved: true }` | +| `POST` | `/api/admin/settings/pricing/sync` | `{ preview: { prices, skipped }, models: { openai: ["gpt-5.4"] } }`;成功返回 `{ saved: true }` | 价目使用 `Provider → 精确上游模型 ID → { multiplierBps, bands }` 的映射。优先级为人工覆盖、已同步价目、 内置价目;按档位合并,不从客户端模型别名或响应模型猜测价格。`syncedAt` 为 ISO 时间或 `null`。 @@ -1060,12 +1059,17 @@ OpenAI 长上下文为输入超过 272000 Token,xAI 为输入达到 200000 Tok 配置,未提供档位重新继承来源;内置和同步均未登记的模型必须包含 `standard`。 - `{ "action": "multiplier", "multiplierBps": 20000 }`:设置目标倍率,保留已有人工单价;重复提交不连续相乘。 - `{ "action": "reset" }`:移除人工单价与倍率,恢复同步价或内置价;仅有人工价格的模型恢复为未配置。 +- `{ "action": "delete" }`:删除非内置模型的同步价目与人工配置;批量包含任何内置模型时整批返回 400, + 即使该内置模型已有人工覆盖也不能删除。删除不影响历史账单,之后可重新添加或选中同步导入。 一批更新原子提交并写审计,不覆盖未选中的模型。未知字段、错误类型和非法价格字符串等 JSON 合同错误 返回 422;Provider、模型 ID、批量数量、倍率上限和不支持的档位等业务校验错误返回 400。 models.dev 同步只导入可表示为当前文本 Token 计价的 OpenAI/xAI 模型;不完整价格、其他输出模态及 不匹配的上下文梯度在 `skipped` 中返回 `provider/model`。确认会重新抓取价目;若与预览不同返回 400, -需重新预览。来源不可用返回 502,已有价目保持不变。同步只更新来源层,始终保留人工单价与倍率。 +需重新预览。来源不可用返回 502,已有价目保持不变。`preview` 必须原样提交,`models` 按 Provider +指定 1~10000 个待同步模型;空选择或未登记的模型返回 400。同步只更新选中模型的来源层,未选中模型 +及所有人工单价与倍率保持不变。选中的已同步模型若不再出现在来源价目中,则移除其来源层,恢复内置价格; +没有内置价目的模型变为未配置。 ### OpenAI 上游客户端身份 diff --git a/docs/architecture.md b/docs/architecture.md index 883e0461e..0bdac5cff 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -133,8 +133,8 @@ service;客户端原生对象保存在有界进程缓存中,与套餐 Redis `RuntimeSnapshot → RoutingPlan → AttemptContext` 冻结传递,关闭时保留客户端原有字段; Provider 在选定账号后应用代理位置覆盖。请求期间不额外查询全局设置,配置发布不改变已开始请求的全局值。 -Fast 限制由同一快照链路冻结:全局开关与 Client Key 所有绑定分组的开关取逻辑或; -禁用分组仍贡献限制,无分组 Key 只受全局开关影响,不按所选账号的分组重新解释。 +Fast 限制由同一快照链路冻结:Client Key 所有绑定分组的开关取逻辑或; +禁用分组仍贡献限制,无分组 Key 不限制 Fast,不按所选账号的分组重新解释。 OpenAI Provider 在独立编码请求上、生成上游头与观测前统一应用顶层档位覆盖,HTTP、WS 与重试共用。 该策略不改变选号、会话亲和或共享原始请求;每个新 WS 请求重新取得当前策略。 WS 路由提示属于握手,连接复用时不重发;档位变化不重建连接或打断 `previous_response_id` 续写, diff --git a/docs/plans/upstream-client-profiles.md b/docs/plans/upstream-client-profiles.md deleted file mode 100644 index 9f13001bc..000000000 --- a/docs/plans/upstream-client-profiles.md +++ /dev/null @@ -1,266 +0,0 @@ -# 上游客户端身份配置实施计划 - -状态:实现与本地验收完成,真实上游账号验收尚未覆盖。当前实现位于 `feat/upstream-client-profiles` 独立工作区。 -代码核对基线:`3328a435`。 - -## 1. 目标与已确定的规则 - -为 OpenAI 上游请求提供 macOS、Linux、Windows 与 Desktop、CLI 组合的六套客户端身份预设, -支持自定义,并允许管理员在 Client Key 中单独选择,覆盖通用设置。 - -配置只保留两层: - -```text -Key 有独立配置 → 使用 Key 配置 -Key 跟随通用设置 → 使用通用设置 -``` - -- 新安装的通用设置默认使用 **macOS · Desktop · 自动最新**,管理员可以修改。 -- 新建及升级前已有的 Key 默认「跟随通用设置」。 -- 内置默认值只用于初始化通用设置,不参与运行时配置优先级。 -- 修改通用设置后,跟随它的 Key 在新请求中自动生效;独立配置的 Key 保持自己的选择。 -- Key 切回「跟随通用设置」时清除独立覆盖,不保留参与运行时合并的隐藏字段。 -- 独立覆盖以完整配置为单位,不逐字段混合 Key 与通用设置,避免组合出不一致的身份。 - -本文中的客户端身份指网关发给上游的 User-Agent、originator、平台和版本等信息。 -该功能不改变接入客户端的最低版本门禁、客户端安装方式或网关宿主操作系统。 - -## 2. 当前实现与可复用入口 - -| 现有能力 | 代码入口 | 本次需要补齐的部分 | -| --- | --- | --- | -| 启动时的 Desktop 身份配置,默认 macOS | [OpenAI 配置](../../backend/crates/providers/openai/src/config.rs) | 将用户选择纳入持久化通用设置,支持客户端类型与平台选择 | -| 共享请求画像、Desktop 发布检查及版本元组更新 | [请求画像](../../backend/crates/providers/openai/src/transport/profile/mod.rs) | 按类型与平台解析版本,支持固定版本以及每请求独立身份 | -| 模型、账号及 Desktop 专属请求的头部组装 | [请求头](../../backend/crates/providers/openai/src/transport/headers.rs) | 按调用场景消费解析后的身份,保留不同接口的协议边界 | -| 通用设置管理 | [管理模型](../../backend/crates/gateway-admin/src/model/settings.rs)、[持久化](../../backend/crates/gateway-store/src/postgres/runtime_settings.rs) | 保存全局选择,复用事务、审计及配置发布 | -| Key 管理 | [管理模型](../../backend/crates/gateway-admin/src/model/client_keys.rs)、[管理用例](../../backend/crates/gateway-admin/src/use_case/client_keys.rs) | 保存可空的独立覆盖,并提供实际生效配置的投影 | -| 请求策略冻结与发布 | [运行快照](../../backend/crates/gateway-core/src/routing/snapshot.rs)、[快照发布](../../backend/crates/gateway-core/src/runtime/mod.rs) | 携带配置选择,保持同一请求及重试的一致性 | -| 上游连接池已有身份区分与精确续写;模型目录已有缓存隔离 | [WebSocket](../../backend/crates/providers/openai/src/transport/websocket/)、[账号目录](../../backend/crates/providers/openai/src/credential/catalog.rs) | 复用现有机制,核对新增身份字段是否完整进入隔离维度 | -| 设置页及首页身份展示 | [设置页](../../frontend/src/views/settings/)、[身份卡片](../../frontend/src/views/dashboard/components/WireProfileCard.vue) | 增加配置入口,明确全局展示与 Key 实际生效值的区别 | - -目前画像类型包含 Desktop 专属字段,自动更新通过同一 Desktop 制品取得应用版本、Core 版本及构建号。 -因此,六套配置需要按官方身份语义扩展,不能仅替换现有 UA 的操作系统名称。 - -## 3. 预设、自定义与版本策略 - -### 3.1 六套预设 - -| 平台 | Desktop | CLI | -| --- | --- | --- | -| macOS | macOS Desktop | macOS CLI | -| Linux | Linux Desktop | Linux CLI | -| Windows | Windows Desktop | Windows CLI | - -平台与客户端类型决定预设;系统版本、CPU 架构及终端信息是预设参数,不再展开成额外的顶级选项。 -预设值由后端统一提供,前端不维护另一份默认值和 UA 拼接规则。 - -实施前逐项核对官方源码或官方制品,记录版本、提交或制品标识、请求格式和更新来源。 -尤其需要核实 Linux Desktop 的可用制品及身份依据,不能因为有六个组合就宣称六套均已获得官方验证。 -没有可靠官方依据的组合保留自定义配置入口,标明核验状态;不伪造官方发布版本或自动更新来源。 - -### 3.2 自定义 - -通用设置和 Key 独立配置共用一套编辑模型:选择预设后,可以调整客户端标识、系统版本、 -架构、终端及适用的版本字段,并预览最终请求身份。 - -- 首期将自定义内容保存在对应通用设置或 Key 中;命名模板库、共享模板引用及模板管理不属于本次范围。 -- 后端按客户端类型生成 UA 与相关身份头;不增加任意请求头透传或任意发布源 URL 配置。 -- 校验必填项、字段长度、HTTP 头合法性、控制字符及客户端类型对应的版本格式。 -- Desktop 与 CLI 使用各自适用的字段;CLI 不携带由 Desktop 默认值遗留的应用版本后缀。 -- 自定义值与「官方核验」状态分别展示,用户填写的值不能自行获得官方核验标记。 -- 认证信息、账号 ID、Cookie、请求位置及数据驻留仍由现有职责管理,不纳入身份自定义。 - -### 3.3 自动最新与固定版本 - -| 模式 | 行为 | -| --- | --- | -| 自动最新(默认) | 跟随该客户端、平台及适用架构对应的最新已核验稳定发布;后台更新,不在业务请求中访问发布源 | -| 固定版本 | 使用保存的版本;后台发布检查不能覆盖固定值 | - -自动最新只更新版本资料,不覆盖用户的平台、架构、终端等选择。手动修改版本时转为固定版本, -避免保存后又被后台任务覆盖。恢复自动最新后,重新采用对应发布源的有效版本。 - -- Desktop 应用版本、内嵌 Core 版本和构建号作为同一制品的完整元组更新;不拼接独立 CLI 的最新版本。 -- CLI 使用对应官方 CLI 发布信息,不借用 Desktop 发布号。 -- 不假设不同平台的 Desktop 同步发布或共享构建号。 -- 更新失败保留同一预设上次成功的版本,并展示检查失败及检查时间;不得切到其他平台或客户端。 -- 首次离线启动可使用同一预设随版本附带的已核验版本资料,并标明未完成最新检查。 - 这些资料属于版本解析器的种子数据,不是第三层用户配置。 -- 没有有效发布源或启动资料的组合不能进入可用的「自动最新」状态;提供原因与固定自定义入口。 -- Redis 中的发布缓存至少区分客户端类型、平台和影响制品选择的架构,不再仅凭 Provider 区分。 - 缓存失效和重启后的恢复也不得覆盖用户的固定配置。 - -## 4. 配置合同与职责 - -### 4.1 持久化与管理 API - -拟增加的概念字段如下,最终名称在实施时与现有 API 风格统一: - -| 位置 | 字段 | 语义 | -| --- | --- | --- | -| 通用设置 | `openaiClientProfile` | 必须存在的完整配置选择,包含预设或自定义参数、版本模式 | -| Client Key | `openaiClientProfileOverride` | `null` 表示跟随通用设置;对象表示完整独立配置 | -| 只读投影 | `effectiveOpenaiClientProfile` | 返回当前解析结果、配置来源、版本来源及检查状态,不作为另一份可写配置 | - -新建 Key 省略覆盖字段等同于 `null`;更新 Key 省略字段保留已有覆盖,显式 `null` 才清除。 -旧调用方更新通用设置时省略新增字段,应保留现值,不能重置管理员已保存的选择。 -通用设置不接受以 `null` 制造第三层回退的语义。 - -配置由 PostgreSQL 保存。管理写入沿用现有事务、脱敏审计、revision 和快照发布流程。 -官方发布结果继续作为 Provider 的派生缓存,不将每次版本检查写成用户配置或推进全局配置 revision。 - -职责依照 [系统架构](../architecture.md): - -- Admin 负责全局与 Key 的管理规则和继承选择,复用同一解析入口生成生效投影。 -- Core 负责请求策略的携带与冻结,不依赖具体 Provider,也不解释 UA、官方制品或请求头。 -- OpenAI Provider 负责预设、官方版本解析、字段语义校验及上游头部生成,通过现有端口风格提供能力。 -- Store 实现配置、迁移与缓存存取,不解释客户端身份的业务规则。 -- API 负责 wire 校验及投影,前端负责编辑与展示,不重复推导最终身份。 - -### 4.2 生效范围 - -本次配置作用于 OpenAI Provider。Key 同时授权其他 Provider 时,不把 OpenAI 身份套用到 xAI。 - -由 Client Key 发起的 OpenAI 模型请求及其相关目录读取,消费该 Key 的有效身份; -HTTP/SSE、WebSocket 以及 Images、Search 等入口按实际调用链逐一核对覆盖。 -OAuth 与 API Key 上游认证模式继续共用适用的身份规则,认证字段仍按各自协议生成。 - -后台令牌刷新、额度查询、账号管理及 Desktop 专属接口没有 Client Key 上下文,不能被某个 Key 的选择污染。 -实施时按官方接口要求划分共用身份与 Desktop 专属身份,保留必要的独立请求格式; -全局选为 CLI 时也不能让 Desktop 专属接口意外携带缺失或错误的应用版本。 - -### 4.3 请求一致性与资源隔离 - -本节以接入现有机制为主。当前鉴权已持有 `RuntimeSnapshot`,重试通过原路由计划传递冻结策略; -WebSocket 连接池已将 `originator`、`user-agent` 等纳入连接身份,且已有 -[画像更新后保留精确续写所属连接的测试](../../backend/crates/providers/openai/tests/transport/websocket_pool.rs)。 -实施沿用这些快照、重试和续写机制,补齐新增配置的数据传递与必要隔离维度。 - -- 将通用与 Key 的身份选择接入已有配置快照,沿现有请求上下文传入 Provider, - 同一逻辑请求的重试与换号继续使用冻结的选择。 -- 自动发布版本目前属于 Provider 的共享运行时画像,`response_headers` 在组装头部时读取 - `self.profile.snapshot()`,尚未由 `RuntimeSnapshot` 冻结。 - 为满足跨重试也使用同一解析版本的目标,在已有请求生命周期内保存首次解析结果并复用; - 这部分与用户配置的快照继承分别验证,不改动重试决策和状态机。 -- 禁止通过临时修改共享 `CodexWireProfileState` 实现 Key 覆盖;并发 Key 使用独立的不可变请求身份。 -- 同一入站 WebSocket 的每个 `response.create` 按现有规则获取新策略。 - 普通连接获取复用现有身份匹配规则;精确续写继续使用持有响应状态的原连接, - 保留已有的画像更新例外,不能因版本刷新强制断开或重建这类连接。 -- 检查新增字段是否已被现有连接身份和模型目录缓存键覆盖,仅补齐影响协议结果的缺失维度。 - Key 授权边界沿用现有机制;身份切换继续遵循原有续写和重放限制,避免上下文丢失或越界重放。 -- 「当前生效」投影显示解析结果;请求观测使用本次冻结的摘要,不能事后用最新全局值替代。 - -## 5. 管理端交互 - -### 通用设置 - -新增「上游客户端身份」配置区,包含平台、客户端类型、预设/自定义、版本策略以及相应字段。 -默认展示 macOS Desktop 自动最新;提供最终 UA、相关版本、发布检查状态及字段错误提示。 -保存后通过现有设置发布机制对新请求生效,无需重启。 - -### Key 创建与编辑 - -默认选择「跟随通用设置」,同时展示当前通用配置摘要。 -选择「独立配置」后使用与通用设置相同的编辑组件;首次切换可用当前通用值填充表单草稿, -只有保存后才形成独立配置。摘要明确标出「通用设置」或「Key 独立配置」。 - -### 现有展示 - -首页身份卡片标明展示的是通用配置,不将其描述成所有 Key 的唯一身份。 -Key 详情展示该 Key 当前有效身份;复用后端投影,不在浏览器拼装 UA 或解析版本。 -复用现有基础组件及 [管理端主题](../theme.md),提供保存失败、版本检查失败、无可用发布源等状态。 - -## 6. 升级与迁移 - -- 按 [迁移规则](../../backend/migrations/README.md) 新增编号迁移,更新冻结清单,不修改历史迁移。 -- 新安装在初始化阶段写入通用默认配置;存量 Key 的覆盖初始化为 `null`。 -- 升级需识别既有 `openai.wire_profile` 自定义值,首次导入通用配置时保留有效的环境与身份选择。 - 标准配置映射到 macOS Desktop 自动最新;显式自定义版本映射到固定自定义,并在升级说明交代。 -- SQL 迁移不负责读取部署文件;必要的一次性导入由启动组装流程经正式管理/存储端口完成, - 在设置尚未初始化时原子写入。初始化标记避免重启或更新重新覆盖数据库中的设置。 -- 实施前核实配置加载器能否区分显式自定义与默认填充值,必要时在加载阶段保留该来源信息。 -- 初始化完成后,相关用户选择以数据库为唯一权威;清理旧配置的运行时覆盖路径并同步配置样例。 - 其他仍有效的 Provider 配置字段继续归原 owner 管理。 -- 备份与恢复包含通用配置和 Key 覆盖;发布缓存仍可重建,不成为配置恢复的前置依赖。 - -## 7. 实施顺序 - -1. **核对官方依据与接口范围。** 完成六个组合的来源矩阵,明确可自动更新的组合、 - Desktop 专属接口、CLI 格式及旧配置来源识别方式,记录无法核验的项目。 -2. **定义合同并打通持久化。** 确定完整配置对象、继承语义及只读投影;实现新增迁移、 - 一次性初始化、设置与 Key 管理 API,复用审计和快照发布。 -3. **扩展 Provider 解析。** 复用现有 Desktop 版本核验,补充 CLI 及平台来源、固定版本、 - 自定义校验和分维度缓存;确保官方资料与用户配置分别存储。 -4. **接入实际请求链路。** 复用现有快照与请求上下文携带有效选择,补齐解析版本的请求级复用, - 覆盖相关传输与目录入口;复用连接池身份匹配与精确续写规则,仅补齐必要的隔离维度。 -5. **实现设置与 Key 界面。** 复用一套编辑组件和服务端投影,调整首页身份卡片含义, - 完成浏览器交互验收。 -6. **验证并同步正式文档。** 完成下列矩阵;在行为可用后就地更新 API、架构、部署和用户入口的必要说明, - 不提前将本计划中的拟定合同写成已发布能力。 - -## 8. 验收条件 - -| 范围 | 必须覆盖的场景与结果 | -| --- | --- | -| 默认与继承 | 新安装采用约定默认;新旧 Key 跟随;通用修改只影响跟随者;独立配置不受影响;显式清除后恢复继承 | -| API 更新语义 | 省略覆盖字段保留原值,显式 `null` 清除;旧设置调用不重置新配置;非法配置不留下部分提交 | -| 预设与自定义 | 六个组合均有明确支持状态;已核验预设与对应官方证据一致;自定义可保存、重启恢复并生成合法头部 | -| 版本解析 | 自动最新按来源更新;固定值不被刷新;Desktop 元组一致;检查失败保留同组合旧版本;无来源状态如实显示 | -| 并发与冻结 | 两个不同配置的 Key 并发请求不串用身份;配置或发布刷新期间,已开始请求及其重试保持一致 | -| 上游传输 | 在可用的真实测试环境检查 HTTP/SSE、WebSocket 及受影响接口发出的身份与结果;API Key 认证不混入 OAuth 字段 | -| 缓存与续写 | 不同有效身份不误用目录结果或连接;配置切换后的新请求应用新身份;旧 continuation 无上下文丢失或越界重放 | -| 管理与后台 | Key 覆盖不影响其他 Key、后台任务及 Desktop 专属操作;全局 CLI 与混合 Provider 授权场景可用 | -| 升级与恢复 | 标准配置、自定义配置、重复启动、数据库备份恢复和发布缓存缺失均按约定处理;历史迁移校验不变 | -| 界面 | 创建/编辑/清除覆盖、保存与加载失败、自动与固定切换、来源及核验状态正确;检查窄屏、深浅色和键盘操作 | - -后端补充能验证上述真实行为的用例,按 [贡献与审查](../../CONTRIBUTING.md#验证) 和 -[架构验收入口](../architecture.md#12-修改与验收) 执行适用的 Rustfmt、Clippy、测试、架构边界与迁移检查。 -数据库与 Redis 使用专用测试实例,因环境缺失跳过的测试明确记录为缺口。 - -前端执行 `pnpm --dir frontend format:check`、`pnpm --dir frontend build`,并提供实际浏览器运行证据; -不为本功能引入项目当前不维护的独立前端测试体系。所有变更执行 `git diff --check`。 -构建、单元测试或模拟上游通过不能替代真实协议验收;测试账号和上游环境不足时列明未覆盖组合。 - -## 9. 已核对的官方依据与实现边界 - -- CLI UA 与 originator:官方 `openai/codex` 提交 `4d205c7a4dc36b719679a0356a45b23133732265`, - `codex-rs/login/src/auth/default_client.rs`。CLI 不添加 Desktop 应用后缀。 -- CLI 版本:`https://registry.npmjs.org/@openai%2Fcodex/latest`,2026-09-18 返回 `0.155.0`, - darwin/linux/win32 × arm64/x64 的依赖版本均配套。实现验证包名、稳定 SemVer 和六个依赖,逐制品缓存。 -- macOS Desktop:沿用官方 appcast 与同一 ZIP 的内嵌 Core 核验路径,支持 arm64。 -- Windows Desktop:分别读取官方 `ChatGPT-x64.msix`、`ChatGPT-arm64.msix`,从 `app.asar/package.json` - 获取应用版本和构建号,从同包 `codex.exe` 核验架构和 Core 版本;MSIX 清单版本不作为应用版本。 -- Linux Desktop:依据[官方安装说明](https://learn.chatgpt.com/docs/linux/linux-app),分别读取 amd64/arm64 DEB, - 按 XZ 索引跳读 tar,核验同包 `app.asar/package.json` 与 ELF Core。Windows/Linux 的 Range 请求锁定 ETag, - 未变更时只检查一个字节,变更后完整核验版本元组;独立缓存、检查失败保留已核验版本。 - -身份预览采用独立只读接口,由后端解析全局或所提交的完整覆盖;Key 列表保存并返回覆盖配置, -编辑器按预览结果显示当前生效身份,不在前端生成 UA。 - -## 10. 本地验收记录 - -2026-09-18,独立 PostgreSQL 18、Redis 8 与 `127.0.0.1:28080` 测试实例,使用合成账号与 Key。 - -- `RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml --test main --locked` - 全部 9 个测试套件通过,合计 2420 项,PostgreSQL/Redis 使用上述专用实例。 - 覆盖配置继承、首次导入、旧调用保留字段、清除覆盖、跨重试版本冻结、并发请求身份隔离、制品缓存隔离, - 以及 Windows/Linux 归档解析、架构不符、损坏/截断、ETag 变化、XZ 跨块跳读和发布回退。 -- `cargo +1.97.0 fmt --all --manifest-path backend/Cargo.toml -- --check`、严格 Clippy(所有 target/feature)通过。 -- `pnpm --dir frontend format:check`、`pnpm --dir frontend build`、`git diff --check` 通过。 -- `backend/migrations/.frozen-sha256` 全部校验通过,历史迁移未修改。 -- Chrome 实测通用设置切换并保存 Linux CLI、创建默认继承的 Key、保存 Windows CLI 固定版本覆盖、 - 重载回显与清除覆盖后恢复继承;检查 1920 px 与 390 px、深浅色、Key 弹窗和键盘关闭下拉框。 - 新增表单宽度与相邻设置对齐,文案仅保留短标签与必要状态,浏览器无错误或警告。 -- Chrome 实测 Windows/Linux Desktop 自动模式与各自 UA,Windows 保存后重载回显;验收后恢复原 MacOS Desktop 通用配置。 - 下拉显示 `MacOS`,内部枚举仍为 `macos`,UA 的系统类型仍为 `Mac OS`。 -- 官方发布检查实际成功:macOS Desktop `26.915.31029` / Core `0.155.0-alpha.9`,CLI `0.155.0`。 - Windows 两种架构为 Desktop `26.908.70816` / Core `0.154.0-alpha.6.2` / Build `9275`; - Linux 两种架构为 Desktop `26.915.31029` / Core `0.155.0-alpha.9` / Build `9771`。 - 四个官方包首次核验耗时约 17~30 秒,重复检查约 67~74 毫秒,未重新扫描制品。 -- `cargo audit --file backend/Cargo.lock` 未通过:已有固定依赖 `rustls 0.23.36` 命中 - `RUSTSEC-2026-0285`,另有原有 `chacha20`、`wnaf` 撤回警告;新增归档依赖未报告漏洞。 - 本次未改变受官方 TLS 指纹约束的依赖版本。 - -验证边界:没有真实上游账号,真实认证下的 HTTP/SSE、WebSocket、Images/Search 未进行端到端验收; -本地传输测试不能替代该验收。Windows/Linux Desktop 的 x86_64、arm64 官方制品解析和未变化复用均已实测,真实账号请求仍未覆盖。 -完整备份恢复、生产部署环境及发布源故障下的界面交互尚未实测。 diff --git a/frontend/src/api/modules/pricing.ts b/frontend/src/api/modules/pricing.ts index b4874caae..64086a49a 100644 --- a/frontend/src/api/modules/pricing.ts +++ b/frontend/src/api/modules/pricing.ts @@ -28,12 +28,18 @@ export type PricingChange = | { action: 'replace', pricing: ModelPricing } | { action: 'multiplier', multiplierBps: number } | { action: 'reset' } + | { action: 'delete' } export interface PricingSyncPreview { prices: PricingMap skipped: string[] } +export interface PricingSyncRequest { + preview: PricingSyncPreview + models: Record +} + interface UpdatePricingParam { provider: string models: string[] @@ -68,7 +74,7 @@ export function previewPricingSync() { }) } -export function syncPricing(data: PricingSyncPreview) { +export function syncPricing(data: PricingSyncRequest) { return request({ url: '/api/admin/settings/pricing/sync', method: 'POST', diff --git a/frontend/src/api/modules/settings.ts b/frontend/src/api/modules/settings.ts index 7303df6e0..b9b9dad01 100644 --- a/frontend/src/api/modules/settings.ts +++ b/frontend/src/api/modules/settings.ts @@ -8,7 +8,6 @@ export type RotationStrategy = 'smart' | 'quota_reset_priority' | 'round_robin' export interface RuntimeSettings { openaiClientProfile: ClientProfileSelection - disableFast: boolean requestLocationEnabled: boolean requestLocation: RequestLocation modelMappings: Record diff --git a/frontend/src/components/AccountGroupCheckboxGrid.vue b/frontend/src/components/AccountGroupCheckboxGrid.vue index 78ae3dbea..6c6750f05 100644 --- a/frontend/src/components/AccountGroupCheckboxGrid.vue +++ b/frontend/src/components/AccountGroupCheckboxGrid.vue @@ -47,6 +47,6 @@ function updateGroup(groupId: string, selected: boolean) {

- {{ loading ? '正在加载分组...' : '暂无分组,请先在分组管理中创建。' }} + {{ loading ? '正在加载分组...' : '暂无分组,请先在分组管理中创建' }}

diff --git a/frontend/src/components/base/BaseMarkdown/index.vue b/frontend/src/components/base/BaseMarkdown/index.vue new file mode 100644 index 000000000..af8536ab2 --- /dev/null +++ b/frontend/src/components/base/BaseMarkdown/index.vue @@ -0,0 +1,33 @@ + + + diff --git a/frontend/src/components/base/BaseMarkdown/markdown.css b/frontend/src/components/base/BaseMarkdown/markdown.css new file mode 100644 index 000000000..c028f4a31 --- /dev/null +++ b/frontend/src/components/base/BaseMarkdown/markdown.css @@ -0,0 +1,102 @@ +.markdown-body { + color: var(--cp-color-text); + font-family: var(--font-sans); + font-size: var(--cp-font-size-sm); + font-weight: var(--font-weight-emphasis); + line-height: 1.65; + overflow-wrap: anywhere; +} + +.markdown-body * { + box-sizing: border-box; + max-width: 100%; +} + +.markdown-body > :first-child { + margin-top: 0; +} + +.markdown-body > :last-child { + margin-bottom: 0; +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + margin: 12px 0 6px; + color: var(--cp-color-text); + font-size: inherit; + font-weight: var(--font-weight-heavy); + line-height: 1.4; +} + +.markdown-body p { + margin: 0 0 8px; +} + +.markdown-body ul, +.markdown-body ol { + margin: 0 0 8px; + padding-left: 1.5em; +} + +.markdown-body ul { + list-style-type: disc; +} + +.markdown-body ol { + list-style-type: decimal; +} + +.markdown-body li { + margin: 3px 0; +} + +.markdown-body a { + color: var(--cp-color-link); + font-weight: var(--font-weight-heavy); + text-decoration: none; +} + +.markdown-body a:hover { + color: var(--cp-color-link-hover); + text-decoration: underline; +} + +.markdown-body a:focus-visible { + border-radius: var(--cp-border-radius-sm); + outline: 2px solid var(--cp-control-outline); + outline-offset: 2px; +} + +.markdown-body code { + border-radius: var(--cp-border-radius-sm); + background: var(--cp-color-fill-tertiary); + color: var(--cp-color-text); + font-family: var(--font-mono); + font-size: var(--cp-font-size-xs); + padding: 1px 5px; +} + +.markdown-body pre { + margin: 8px 0; + overflow-x: auto; + border-radius: var(--cp-border-radius); + background: var(--cp-color-bg-container); + padding: 8px 10px; +} + +.markdown-body pre code { + background: transparent; + padding: 0; +} + +.markdown-body blockquote { + margin: 8px 0; + border-left: 3px solid var(--cp-color-split); + color: var(--cp-color-text-secondary); + padding-left: 10px; +} diff --git a/frontend/src/layout/components/SystemUpdateModal/markdown.ts b/frontend/src/components/base/BaseMarkdown/markdown.ts similarity index 86% rename from frontend/src/layout/components/SystemUpdateModal/markdown.ts rename to frontend/src/components/base/BaseMarkdown/markdown.ts index 649f22f5c..160179c1d 100644 --- a/frontend/src/layout/components/SystemUpdateModal/markdown.ts +++ b/frontend/src/components/base/BaseMarkdown/markdown.ts @@ -7,7 +7,7 @@ const marked = new Marked({ gfm: true, }) -export function renderReleaseNotes(source?: string | null) { +export function renderMarkdown(source?: string | null) { const value = source?.trim() if (!value) return '' diff --git a/frontend/src/components/base/BaseModal/index.vue b/frontend/src/components/base/BaseModal/index.vue index 7c7f26dda..64a8752fd 100644 --- a/frontend/src/components/base/BaseModal/index.vue +++ b/frontend/src/components/base/BaseModal/index.vue @@ -102,10 +102,17 @@ function closeModal() { open.value = false } -function focusableElements() { - return Array.from(panel.value?.querySelectorAll(focusableSelector) ?? []).filter( - element => !element.hidden, - ) +function focusableElements(root: ParentNode | null = panel.value): HTMLElement[] { + if (!root) + return [] + + const elements: HTMLElement[] = [] + for (const element of root.children) { + if (element instanceof HTMLElement && element.matches(focusableSelector) && !element.hidden) + elements.push(element) + elements.push(...focusableElements(element.shadowRoot ?? element)) + } + return elements } function handleKeydown(event: KeyboardEvent) { @@ -125,17 +132,20 @@ function handleKeydown(event: KeyboardEvent) { } const first = focusable[0] const last = focusable[focusable.length - 1] + let activeElement = document.activeElement + while (activeElement?.shadowRoot?.activeElement) + activeElement = activeElement.shadowRoot.activeElement if (!panel.value?.contains(document.activeElement)) { event.preventDefault() if (event.shiftKey) last?.focus() else first?.focus() } - else if (event.shiftKey && document.activeElement === first) { + else if (event.shiftKey && activeElement === first) { event.preventDefault() last?.focus() } - else if (!event.shiftKey && document.activeElement === last) { + else if (!event.shiftKey && activeElement === last) { event.preventDefault() first?.focus() } diff --git a/frontend/src/components/base/BaseTable/index.vue b/frontend/src/components/base/BaseTable/index.vue index e17d02824..b770a72f1 100644 --- a/frontend/src/components/base/BaseTable/index.vue +++ b/frontend/src/components/base/BaseTable/index.vue @@ -55,7 +55,8 @@ const tableRef = useTemplateRef('table') const horizontalScrolled = shallowRef(false) const horizontalCanScrollRight = shallowRef(false) -function measureHorizontalScroll() { +function updateScrollLayout() { + scrollbarRef.value?.update() const wrap = scrollbarRef.value?.wrapRef if (!wrap) { horizontalScrolled.value = false @@ -78,12 +79,12 @@ function handleTableScroll(payload: { scrollTop: number, scrollLeft: number }) { onMounted(async () => { await nextTick() - measureHorizontalScroll() + updateScrollLayout() }) -useResizeObserver(() => [scrollbarRef.value?.wrapRef, tableRef.value].filter(Boolean), measureHorizontalScroll) +useResizeObserver(() => [scrollbarRef.value?.wrapRef, tableRef.value].filter(Boolean), updateScrollLayout) watch([() => displayRows.value.length, () => props.columns], async () => { await nextTick() - measureHorizontalScroll() + updateScrollLayout() }) const headerRowClass = computed(() => [ diff --git a/frontend/src/components/client-profile/ClientProfileEditor.vue b/frontend/src/components/client-profile/ClientProfileEditor.vue index 0808e22ff..c2439a459 100644 --- a/frontend/src/components/client-profile/ClientProfileEditor.vue +++ b/frontend/src/components/client-profile/ClientProfileEditor.vue @@ -7,10 +7,12 @@ import BaseFormItem from '@/components/base/BaseForm/FormItem.vue' import BaseInput from '@/components/base/BaseInput.vue' import BaseSegmented from '@/components/base/BaseSegmented.vue' import BaseSelect from '@/components/base/BaseSelect.vue' +import BaseSkeleton from '@/components/base/BaseSkeleton.vue' import { errorMessage } from '@/utils/async' import { formatDateTime } from '@/utils/date' -withDefaults(defineProps<{ disabled?: boolean, allowInherit?: boolean }>(), { +const props = withDefaults(defineProps<{ active?: boolean, disabled?: boolean, allowInherit?: boolean }>(), { + active: true, disabled: false, allowInherit: false, }) @@ -22,7 +24,6 @@ const loading = shallowRef(true) const loadError = shallowRef('') const previewError = shallowRef('') const previewing = shallowRef(false) -const custom = shallowRef(false) const platforms = { macos: 'MacOS', linux: 'Linux', windows: 'Windows' } const presetOptions = computed(() => presets.value.map(({ configuration }) => ({ value: `${configuration.platform}-${configuration.client}`, @@ -46,10 +47,8 @@ const selectedPreset = computed({ get: () => model.value ? `${model.value.platform}-${model.value.client}` : '', set: (value: string) => { const preset = presets.value.find(({ configuration }) => `${configuration.platform}-${configuration.client}` === value) - if (preset) { + if (preset) model.value = { ...preset.configuration, versionMode: preset.automaticAvailable ? 'latest' : 'fixed' } - custom.value = false - } }, }) const versionMode = computed({ @@ -95,12 +94,12 @@ async function load() { } } -watch(model, (configuration, _, onCleanup) => { +watch([model, () => props.active], ([configuration, active], _, onCleanup) => { let cancelled = false preview.value = undefined previewError.value = '' - previewing.value = !needsVersionInput.value - if (needsVersionInput.value) + previewing.value = active && !needsVersionInput.value + if (!active || needsVersionInput.value) return const timer = setTimeout(async () => { try { @@ -141,10 +140,10 @@ onMounted(load) - + @@ -170,7 +169,7 @@ onMounted(load) {{ currentPreset.reason }}

- + + + +
-
- - 自定义身份参数 - -
- - - -
-
-
+

填写版本后预览

-

- 正在解析身份… -

+
+ + +
- - diff --git a/frontend/src/router/routes.ts b/frontend/src/router/routes.ts index 5ab838fa4..88fd41324 100644 --- a/frontend/src/router/routes.ts +++ b/frontend/src/router/routes.ts @@ -55,6 +55,16 @@ export const routes: RouteRecordRaw[] = [ name: 'settings', component: () => import('@/views/settings/index.vue'), }, + { + path: 'settings/upstream', + name: 'settings-upstream', + component: () => import('@/views/settings/index.vue'), + }, + { + path: 'settings/access', + name: 'settings-access', + component: () => import('@/views/settings/index.vue'), + }, { path: 'settings/backup', name: 'settings-backup', diff --git a/frontend/src/views/accounts/components/AccountCreateModal/AccountSetupFields.vue b/frontend/src/views/accounts/components/AccountCreateModal/AccountSetupFields.vue index 89a78f586..f90013fe9 100644 --- a/frontend/src/views/accounts/components/AccountCreateModal/AccountSetupFields.vue +++ b/frontend/src/views/accounts/components/AccountCreateModal/AccountSetupFields.vue @@ -43,7 +43,7 @@ const form = defineModel({ required: true }) v-model="form.notes" :rows="3" :maxlength="500" - placeholder="最多 500 字,可不填。" + placeholder="最多 500 字,可不填" :disabled="disabled" /> diff --git a/frontend/src/views/accounts/components/AccountCreateModal/presenter.ts b/frontend/src/views/accounts/components/AccountCreateModal/presenter.ts index 1e67a7c13..c8d69683d 100644 --- a/frontend/src/views/accounts/components/AccountCreateModal/presenter.ts +++ b/frontend/src/views/accounts/components/AccountCreateModal/presenter.ts @@ -84,7 +84,7 @@ function resolveModal( else if (input.form.mode === 'oauth') description = '通过浏览器授权导入 OpenAI 账号' else if (input.form.mode === 'access_token') - description = '逐行粘贴 Access Token;未包含 Refresh Token 时无法自动续期' + description = '逐行粘贴 Access Token,未包含 Refresh Token 时无法自动续期' else if (input.form.mode === 'refresh_token') description = '逐行粘贴 Refresh Token,导入时将自动换取 Access Token' diff --git a/frontend/src/views/accounts/components/AccountEditModal.vue b/frontend/src/views/accounts/components/AccountEditModal.vue index 201c99887..c71bb35f8 100644 --- a/frontend/src/views/accounts/components/AccountEditModal.vue +++ b/frontend/src/views/accounts/components/AccountEditModal.vue @@ -96,7 +96,7 @@ const selectedGroupIds = defineModel('selectedGroupIds', { required: t v-model="notes" :rows="3" :maxlength="500" - placeholder="最多 500 字,留空可清除备注。" + placeholder="最多 500 字,留空可清除备注" :disabled="saving" /> diff --git a/frontend/src/views/accounts/components/AccountProfileModal/TokenActivity.vue b/frontend/src/views/accounts/components/AccountProfileModal/TokenActivity.vue index c9ee5c59c..553488077 100644 --- a/frontend/src/views/accounts/components/AccountProfileModal/TokenActivity.vue +++ b/frontend/src/views/accounts/components/AccountProfileModal/TokenActivity.vue @@ -47,7 +47,7 @@ const monthLabels = computed(() => diff --git a/frontend/src/views/accounts/components/AccountProfileModal/index.vue b/frontend/src/views/accounts/components/AccountProfileModal/index.vue index 4ff7c8b21..d2915f450 100644 --- a/frontend/src/views/accounts/components/AccountProfileModal/index.vue +++ b/frontend/src/views/accounts/components/AccountProfileModal/index.vue @@ -47,7 +47,7 @@ const { profile, subscription, loading, error, load } = useAccountPersonalInfo(a @@ -56,7 +56,7 @@ const { profile, subscription, loading, error, load } = useAccountPersonalInfo(a

- 本次信息刷新未完成,保留已获取的结果。 + 本次信息刷新未完成,保留已获取的结果

diff --git a/frontend/src/views/accounts/components/AccountQuotaForecastModal/index.vue b/frontend/src/views/accounts/components/AccountQuotaForecastModal/index.vue index 7617769dc..f66951aa3 100644 --- a/frontend/src/views/accounts/components/AccountQuotaForecastModal/index.vue +++ b/frontend/src/views/accounts/components/AccountQuotaForecastModal/index.vue @@ -29,13 +29,13 @@ const { report, loading, refreshing, error, load, refresh } = useAccountQuotaFor ) const { now, pause, resume } = useNow({ interval: 30_000, controls: true }) const options = [ - { label: '周额度', value: 'weekly' }, + { label: '7日额度', value: 'weekly' }, { label: '月额度', value: 'monthly' }, ] const forecast = computed(() => report.value?.forecasts.find(item => item.period === period.value)) const unavailableReason = computed(() => { if (forecast.value?.source && new Date(forecast.value.source.resetAt) <= now.value) - return '额度窗口已过期,请刷新账号额度后重试。' + return '额度窗口已过期,请刷新账号额度后重试' return forecast.value?.unavailableReason ?? null }) @@ -85,7 +85,7 @@ function handleExplanationKeydown(event: KeyboardEvent) {
- +