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 }}
-
+
@@ -181,38 +180,36 @@ onMounted(load)
+
+
+
-
-
- 自定义身份参数
-
-
-
-
-
-
-
-
+
填写版本后预览
-
- 正在解析身份…
-
+
{{ previewError }}
{{ preview.userAgent }}
- {{ preview.versionSource === 'custom' ? '固定版本' : '自动更新' }}
+ {{ preview.versionSource === 'custom' ? '自定义版本' : '自动更新' }}
· {{ preview.checkedAt ? `检查于 ${formatDateTime(preview.checkedAt)}` : '待检查' }}
diff --git a/frontend/src/layout/components/SystemUpdateModal/index.vue b/frontend/src/layout/components/SystemUpdateModal/index.vue
index 27b744c3c..9d8918bca 100644
--- a/frontend/src/layout/components/SystemUpdateModal/index.vue
+++ b/frontend/src/layout/components/SystemUpdateModal/index.vue
@@ -14,13 +14,13 @@ import { computed, nextTick, shallowRef, useTemplateRef, watch } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
+import BaseMarkdown from '@/components/base/BaseMarkdown/index.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import { toast } from '@/components/base/BaseToast'
import { normalizeSystemVersion, useSystemUpdateStore } from '@/stores/modules/system-update'
import { errorMessage } from '@/utils/async'
import { formatTime } from '@/utils/date'
-import { renderReleaseNotes } from './markdown'
import {
resolveSystemUpdateLogClasses,
resolveSystemUpdatePresentation,
@@ -77,7 +77,7 @@ const updateLogRows = computed(() =>
})),
)
-const renderedReleaseNotes = computed(() => renderReleaseNotes(updateInfo.value?.notes))
+const hasReleaseNotes = computed(() => Boolean(updateInfo.value?.notes?.trim()))
const showUpdateProgress = computed(
() => hasUpdate.value || updating.value || restarting.value || updateLogRows.value.length > 0,
@@ -256,7 +256,7 @@ watch(
@@ -269,7 +269,7 @@ watch(
@@ -401,89 +401,3 @@ watch(
-
-
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) {
-
+
重新加载
@@ -122,16 +122,16 @@ function handleExplanationKeydown(event: KeyboardEvent) {
仅供参考
- 按已记录数据估算,缺失的用量或费用可能使结果偏低。结果会随使用的模型和方式变化,并非官方承诺额度。
+ 按已记录数据估算,缺失的用量或费用可能使结果偏低,结果会随使用的模型和方式变化,并非官方承诺额度
- 目前数据还较少,结果可能有较大波动。
+ 目前数据还较少,结果可能有较大波动
- 本页预测为折算值,剩余量仍按{{ forecast.source?.label ?? '当前周期' }}计算。
+ 本页预测为折算值,剩余量仍按{{ forecast.source?.label ?? '当前周期' }}计算
- 剩余量以数据更新时间为准;等价费用不是实际账单或账户余额。
+ 剩余量以数据更新时间为准,等价费用不是实际账单或账户余额
diff --git a/frontend/src/views/accounts/components/AccountQuotaPanel/ResetCredits.vue b/frontend/src/views/accounts/components/AccountQuotaPanel/ResetCredits.vue
index c927bb70a..dd65eba3a 100644
--- a/frontend/src/views/accounts/components/AccountQuotaPanel/ResetCredits.vue
+++ b/frontend/src/views/accounts/components/AccountQuotaPanel/ResetCredits.vue
@@ -156,7 +156,7 @@ function handleRequestConsume(creditId: string) {
上次操作结果待确认
- 请继续确认上次重置结果。
+ 请继续确认上次重置结果
@@ -200,7 +200,7 @@ function handleRequestConsume(creditId: string) {
class="mx-4 mt-0 mb-4 rounded-cp bg-cp-error-container px-4 py-3 text-cp-xs leading-normal font-emphasis text-cp-error-on-container"
role="status"
>
- {{ loadError }},请刷新重试。
+ {{ loadError }},请刷新重试
Math.max(summaryEntries.value.length
{{ account.usage.windowLabelDisplay }}
-
+
diff --git a/frontend/src/views/accounts/components/AccountStatusBadge/presenter.ts b/frontend/src/views/accounts/components/AccountStatusBadge/presenter.ts
index c95ae5f31..1a628d141 100644
--- a/frontend/src/views/accounts/components/AccountStatusBadge/presenter.ts
+++ b/frontend/src/views/accounts/components/AccountStatusBadge/presenter.ts
@@ -90,53 +90,53 @@ const displayDefinitions: Record = {
- account_unverified: '重新授权后,账号会重新参与调度。',
- access_token_expired: '重新授权后,账号会重新参与调度。',
- credential_expired: '重新授权后,账号会重新参与调度。',
- credential_invalid: '请更新或重新导入凭据,再次测试连接。',
- account_banned: '请确认上游账号状态,解除限制后再启用。',
+ account_unverified: '重新授权后,账号会重新参与调度',
+ access_token_expired: '重新授权后,账号会重新参与调度',
+ credential_expired: '重新授权后,账号会重新参与调度',
+ credential_invalid: '请更新或重新导入凭据,再次测试连接',
+ account_banned: '请确认上游账号状态,解除限制后再启用',
}
export function resolveAccountStatusPresentation(
@@ -154,10 +154,10 @@ export function resolveAccountStatusPresentation(
const definition = isFreeze
? {
...displayDefinitions.rate_limited,
- description: '容量类请求失败累计达到阈值,系统已暂停该账号的调度。',
+ description: '容量类请求失败累计达到阈值,系统已暂停该账号的调度',
recoveryHint: waitsForProbe
- ? '冷却结束后进行恢复探测,成功后恢复调度;也可手动恢复账号。'
- : '冷却结束后自动恢复调度,也可手动恢复账号。',
+ ? '冷却结束后进行恢复探测,成功后恢复调度,也可手动恢复账号'
+ : '冷却结束后自动恢复调度,也可手动恢复账号',
}
: displayDefinitions[mode]
const nextRefreshDisplay = isBackoff ? formatDateTime(nextRefreshTimestamp) : null
@@ -175,8 +175,8 @@ export function resolveAccountStatusPresentation(
? errorRecoveryHints[input.errorReason]
: definition.recoveryHint
const hasDetail = input.status === 'error' || mode === 'rate_limited' || isBackoff
- const retry = nextRefreshDisplay ? ` 下次尝试:${nextRefreshDisplay}。` : ''
- const triggerLabel = `${title}。${definition.description}${retry} 点击或聚焦查看详情。`
+ const retry = nextRefreshDisplay ? `,下次尝试:${nextRefreshDisplay}` : ''
+ const triggerLabel = `${title},${definition.description}${retry},点击或聚焦查看详情`
return {
mode,
diff --git a/frontend/src/views/accounts/composables/useAccountResetCredits.ts b/frontend/src/views/accounts/composables/useAccountResetCredits.ts
index a2830bdc7..9f5e812dd 100644
--- a/frontend/src/views/accounts/composables/useAccountResetCredits.ts
+++ b/frontend/src/views/accounts/composables/useAccountResetCredits.ts
@@ -223,7 +223,7 @@ export function useAccountResetCredits(options: {
...operation,
hasTransportFailure: true,
}
- toast.warning('消费结果暂不确定;重试会复用同一个请求标识', { duration: 5000 })
+ toast.warning('消费结果暂不确定,重试会复用同一个请求标识', { duration: 5000 })
}
else {
target.pendingOperation = null
diff --git a/frontend/src/views/dashboard/components/HealthTimelinePointPopover.vue b/frontend/src/views/dashboard/components/HealthTimelinePointPopover.vue
index dd3d8c7fa..f255ebebf 100644
--- a/frontend/src/views/dashboard/components/HealthTimelinePointPopover.vue
+++ b/frontend/src/views/dashboard/components/HealthTimelinePointPopover.vue
@@ -126,7 +126,7 @@ const metricItems = outcomeMeta
- 客户端取消与调用方错误单独记录,不计入可用性。
+ 客户端取消与调用方错误单独记录,不计入可用性
diff --git a/frontend/src/views/groups/components/AccountGroupFormModal.vue b/frontend/src/views/groups/components/AccountGroupFormModal.vue
index 13e110cf5..afec3a3ce 100644
--- a/frontend/src/views/groups/components/AccountGroupFormModal.vue
+++ b/frontend/src/views/groups/components/AccountGroupFormModal.vue
@@ -9,7 +9,7 @@ import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseForm from '@/components/base/BaseForm/index.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
-import BaseSwitch from '@/components/base/BaseSwitch.vue'
+import BaseSegmented from '@/components/base/BaseSegmented.vue'
import BaseTextarea from '@/components/base/BaseTextarea.vue'
import { ACCOUNT_GROUP_COLOR_PRESETS } from '../constants'
@@ -24,8 +24,8 @@ const open = defineModel({ required: true })
const form = defineModel('form', { required: true })
const title = computed(() => props.group ? '编辑分组' : '创建分组')
const description = computed(() => props.group
- ? '修改分组名称、用途说明和 Fast 限制。'
- : '创建后,可在账号管理中将账号加入这个分组。')
+ ? '修改分组名称、用途说明和 Fast 模式'
+ : '创建后,可在账号管理中将账号加入这个分组')
@@ -53,6 +53,19 @@ const description = computed(() => props.group
:disabled="saving"
/>
+
+
+
props.group
:disabled="saving"
/>
-
-
-
diff --git a/frontend/src/views/groups/index.vue b/frontend/src/views/groups/index.vue
index 9aa4ff808..97a082260 100644
--- a/frontend/src/views/groups/index.vue
+++ b/frontend/src/views/groups/index.vue
@@ -187,14 +187,14 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
@confirm="confirmBatchDelete"
>
- 确定删除选中的 {{ selectedIds.size }} 个分组吗?账号本身不会被删除。
+ 确定删除选中的 {{ selectedIds.size }} 个分组吗?账号本身不会被删除
- 将影响 {{ pendingDisableGroup.clientKeyCount }} 个 API 密钥。
+ 将影响 {{ pendingDisableGroup.clientKeyCount }} 个 API 密钥
@@ -220,7 +220,7 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
@confirm="confirmDelete"
>
- 确定删除“{{ pendingDeleteGroup?.name || '该分组' }}”吗?账号本身不会被删除。
+ 确定删除“{{ pendingDeleteGroup?.name || '该分组' }}”吗?账号本身不会被删除
diff --git a/frontend/src/views/key-usage/components/KeyUsageBudget.vue b/frontend/src/views/key-usage/components/KeyUsageBudget.vue
index e29616e28..e53fa3313 100644
--- a/frontend/src/views/key-usage/components/KeyUsageBudget.vue
+++ b/frontend/src/views/key-usage/components/KeyUsageBudget.vue
@@ -8,7 +8,7 @@ import { keyUsageTime, money } from '../utils/format'
const props = defineProps<{ budget: KeyUsageBudget }>()
const windows = computed(() => [
{ label: '今日额度', limit: props.budget.dailyLimitUsd, used: props.budget.dailyUsedUsd, reset: props.budget.dailyResetsAt },
- { label: '七日额度', limit: props.budget.weeklyLimitUsd, used: props.budget.weeklyUsedUsd, reset: props.budget.weeklyResetsAt },
+ { label: '7日额度', limit: props.budget.weeklyLimitUsd, used: props.budget.weeklyUsedUsd, reset: props.budget.weeklyResetsAt },
].map(window => ({
...window,
limited: Number(window.limit) > 0,
diff --git a/frontend/src/views/key-usage/components/KeyUsageRecords.vue b/frontend/src/views/key-usage/components/KeyUsageRecords.vue
index 4e02cdd4f..2026e9102 100644
--- a/frontend/src/views/key-usage/components/KeyUsageRecords.vue
+++ b/frontend/src/views/key-usage/components/KeyUsageRecords.vue
@@ -39,7 +39,7 @@ const columns = computed(() => defineTableColumns([
- {{ error || '请求日志刷新失败,暂时保留上次结果。' }}
+ {{ error || '请求日志刷新失败,暂时保留上次结果' }}
diff --git a/frontend/src/views/key-usage/components/KeyUsageSkeleton.vue b/frontend/src/views/key-usage/components/KeyUsageSkeleton.vue
index 34d5164ff..1930485dd 100644
--- a/frontend/src/views/key-usage/components/KeyUsageSkeleton.vue
+++ b/frontend/src/views/key-usage/components/KeyUsageSkeleton.vue
@@ -56,7 +56,7 @@ const metrics = [
-
+
{{ label }}
diff --git a/frontend/src/views/key-usage/index.vue b/frontend/src/views/key-usage/index.vue
index aaf68bc48..c68291285 100644
--- a/frontend/src/views/key-usage/index.vue
+++ b/frontend/src/views/key-usage/index.vue
@@ -30,7 +30,7 @@ const { items, currentPage, pageSize, total, loading: recordsLoading, error: rec
- {{ overviewError }}{{ overview ? ',暂时保留上次结果。' : ',请点击顶部刷新重试。' }}
+ {{ overviewError }}{{ overview ? ',暂时保留上次结果' : ',请点击顶部刷新重试' }}
diff --git a/frontend/src/views/keys/components/ApiKeyBudgetCell.vue b/frontend/src/views/keys/components/ApiKeyBudgetCell.vue
index 4f9eaf935..f2c86e5ec 100644
--- a/frontend/src/views/keys/components/ApiKeyBudgetCell.vue
+++ b/frontend/src/views/keys/components/ApiKeyBudgetCell.vue
@@ -7,7 +7,7 @@ import { formatDateTime } from '@/utils/date'
const props = defineProps<{ apiKey: ApiKey }>()
const windows = computed(() => [
{ label: '日', heading: '日用量', used: props.apiKey.dailyUsedUsd, limit: props.apiKey.dailyLimitUsd, reset: props.apiKey.dailyResetsAt },
- { label: '周', heading: '周用量', used: props.apiKey.weeklyUsedUsd, limit: props.apiKey.weeklyLimitUsd, reset: props.apiKey.weeklyResetsAt },
+ { label: '7日', heading: '7日用量', used: props.apiKey.weeklyUsedUsd, limit: props.apiKey.weeklyLimitUsd, reset: props.apiKey.weeklyResetsAt },
])
function amount(value: string) {
// 列表最多显示两位小数,不补末尾零;明细保留原始金额的全部精度。
@@ -26,7 +26,7 @@ function amount(value: string) {
aria-haspopup="dialog"
>
- {{ window.label }}
+ {{ window.label }}
${{ amount(window.used) }} / {{ Number(window.limit) === 0 ? '∞' : `$${amount(window.limit)}` }}
diff --git a/frontend/src/views/keys/components/ApiKeyBudgetResetModal.vue b/frontend/src/views/keys/components/ApiKeyBudgetResetModal.vue
index 175416cbd..bd8c8b5eb 100644
--- a/frontend/src/views/keys/components/ApiKeyBudgetResetModal.vue
+++ b/frontend/src/views/keys/components/ApiKeyBudgetResetModal.vue
@@ -1,8 +1,10 @@
-
-
-
- {{ apiKey?.name }}
-
-
-
- 限额上限、原重置时间和历史使用记录保持不变,正在进行的请求会在完成后计入额度
-
+
+
+
目标密钥
+
+ {{ apiKey?.name }}
+
+
+
+
+
+
+
+ 当前已用
+ 重置后
+
+
+
+
-
+ {{ window.label }}
+
+
-
+ ${{ window.used }}
+
+ $0
+ 保持不变
+
+
+
+
-
+
+
+
+ 取消
+
+
+ 确认重置
+
+
+
diff --git a/frontend/src/views/keys/components/ApiKeyCreateModal.vue b/frontend/src/views/keys/components/ApiKeyCreateModal.vue
index 262dc42c2..bbfe7fef8 100644
--- a/frontend/src/views/keys/components/ApiKeyCreateModal.vue
+++ b/frontend/src/views/keys/components/ApiKeyCreateModal.vue
@@ -28,13 +28,14 @@ const emit = defineEmits<{
const open = defineModel
({ default: false })
const createdOpen = defineModel('createdOpen', { default: false })
const form = defineModel('form', { required: true })
-const title = computed(() => props.editing ? '编辑 API Key' : '创建 API Key')
+const title = computed(() => props.editing ? '编辑密钥' : '创建 API Key')
props.editing ? '编辑 API Key' : '创建 API Key'
-
+
diff --git a/frontend/src/views/keys/components/ApiKeyIdentityCell.vue b/frontend/src/views/keys/components/ApiKeyIdentityCell.vue
index b74db5b26..8ccb73657 100644
--- a/frontend/src/views/keys/components/ApiKeyIdentityCell.vue
+++ b/frontend/src/views/keys/components/ApiKeyIdentityCell.vue
@@ -9,12 +9,12 @@ defineProps<{
-
-
+
+
{{ apiKey.name }}
-
- {{ apiKey.label }}
+
+ {{ apiKey.label || '未填写备注' }}
diff --git a/frontend/src/views/keys/components/ApiKeyUseModal.vue b/frontend/src/views/keys/components/ApiKeyUseModal.vue
index 1f488b1a2..a2714553c 100644
--- a/frontend/src/views/keys/components/ApiKeyUseModal.vue
+++ b/frontend/src/views/keys/components/ApiKeyUseModal.vue
@@ -77,7 +77,7 @@ const visibleFiles = computed(() => [
- 配置含密钥,请勿分享,覆盖前请备份。
+ 配置含密钥,请勿分享,覆盖前请备份
diff --git a/frontend/src/views/keys/composables/useApiKeyMutations.ts b/frontend/src/views/keys/composables/useApiKeyMutations.ts
index ee644bdb1..13a590de0 100644
--- a/frontend/src/views/keys/composables/useApiKeyMutations.ts
+++ b/frontend/src/views/keys/composables/useApiKeyMutations.ts
@@ -138,7 +138,7 @@ export function useApiKeyMutations(options: {
}
function validateForm() {
- for (const [label, value] of [['日限额', form.value.dailyLimitUsd], ['周限额', form.value.weeklyLimitUsd]]) {
+ for (const [label, value] of [['日限额', form.value.dailyLimitUsd], ['7日限额', form.value.weeklyLimitUsd]]) {
if (value.trim() && !/^\d{1,10}(?:\.\d{1,10})?$/.test(value.trim())) {
toast.warning(`${label}必须是非负金额,最多 10 位小数`)
return false
diff --git a/frontend/src/views/keys/index.vue b/frontend/src/views/keys/index.vue
index c382256d6..5f230205b 100644
--- a/frontend/src/views/keys/index.vue
+++ b/frontend/src/views/keys/index.vue
@@ -246,13 +246,13 @@ watch(
- 该密钥可以使用所有账号,包括以后新增和未分组的账号。
+ 该密钥可以使用所有账号,包括以后新增和未分组的账号
diff --git a/frontend/src/views/proxies/components/ProxyAccountsModal.vue b/frontend/src/views/proxies/components/ProxyAccountsModal.vue
index bfb5cbf2d..4b2023263 100644
--- a/frontend/src/views/proxies/components/ProxyAccountsModal.vue
+++ b/frontend/src/views/proxies/components/ProxyAccountsModal.vue
@@ -125,7 +125,7 @@ watch([open, () => props.proxy?.id], () => {
- 将“{{ pendingRemove?.name }}”从“{{ proxy?.name }}”移除后,该账号将改为直连。
+ 将“{{ pendingRemove?.name }}”从“{{ proxy?.name }}”移除后,该账号将改为直连
diff --git a/frontend/src/views/proxies/components/ProxyFormModal.vue b/frontend/src/views/proxies/components/ProxyFormModal.vue
index 97bd264cf..46d2a5410 100644
--- a/frontend/src/views/proxies/components/ProxyFormModal.vue
+++ b/frontend/src/views/proxies/components/ProxyFormModal.vue
@@ -29,8 +29,8 @@ const showSecret = shallowRef(false)
const busy = computed(() => props.saving || props.testing)
const title = computed(() => props.proxy ? '编辑代理' : '新增代理')
const connectionDescription = computed(() => props.proxy
- ? '留空保留当前连接和认证信息;填写新地址时,请包含所需的用户名和密码。'
- : '支持 HTTP、HTTPS、SOCKS5 和 SOCKS5H,可在地址中包含用户名和密码。')
+ ? '留空保留当前连接和认证信息,填写新地址时,请包含所需的用户名和密码'
+ : '支持 HTTP、HTTPS、SOCKS5 和 SOCKS5H,可在地址中包含用户名和密码')
watch(open, () => {
showSecret.value = false
@@ -63,7 +63,7 @@ watch(open, () => {
- 将更新 {{ proxy.accountCount }} 个关联账号的出口。
+ 将更新 {{ proxy.accountCount }} 个关联账号的出口
diff --git a/frontend/src/views/proxies/index.vue b/frontend/src/views/proxies/index.vue
index 0a388c58d..ddda2ccdb 100644
--- a/frontend/src/views/proxies/index.vue
+++ b/frontend/src/views/proxies/index.vue
@@ -212,7 +212,7 @@ onMounted(() => void query.execute())
- {{ row.name }}
+ {{ row.name }}
diff --git a/frontend/src/views/settings/components/AccountAutoFreezeCard.vue b/frontend/src/views/settings/components/AccountAutoFreezeCard.vue
index d92f51d9e..7ec67a016 100644
--- a/frontend/src/views/settings/components/AccountAutoFreezeCard.vue
+++ b/frontend/src/views/settings/components/AccountAutoFreezeCard.vue
@@ -22,25 +22,25 @@ const adaptiveConcurrencyHintId = useId()
-
+
修改密码
-
- 修改后,所有已登录的管理员都需要使用新密码重新登录。
-
+
+
+
+
+
+
+
+ 验证当前密码
+
+
+ 修改后,所有管理员需重新登录
+
+
+
diff --git a/frontend/src/views/settings/components/ChangePasswordModal.vue b/frontend/src/views/settings/components/ChangePasswordModal.vue
index 67ed89d57..52e763dab 100644
--- a/frontend/src/views/settings/components/ChangePasswordModal.vue
+++ b/frontend/src/views/settings/components/ChangePasswordModal.vue
@@ -63,9 +63,6 @@ async function submit() {
-
- 保存后,当前及其他设备上的管理员登录都会失效,需要重新登录。
-
diff --git a/frontend/src/views/settings/components/FastPolicyCard.vue b/frontend/src/views/settings/components/FastPolicyCard.vue
deleted file mode 100644
index 630b83d7f..000000000
--- a/frontend/src/views/settings/components/FastPolicyCard.vue
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/frontend/src/views/settings/components/ModelAliasesCard.vue b/frontend/src/views/settings/components/ModelAliasesCard.vue
index 6ad6f0eb3..683ba44c2 100644
--- a/frontend/src/views/settings/components/ModelAliasesCard.vue
+++ b/frontend/src/views/settings/components/ModelAliasesCard.vue
@@ -25,7 +25,7 @@ const emit = defineEmits<{
diff --git a/frontend/src/views/settings/components/RequestLocationCard.vue b/frontend/src/views/settings/components/RequestLocationCard.vue
index aafef2fcb..5442d0e70 100644
--- a/frontend/src/views/settings/components/RequestLocationCard.vue
+++ b/frontend/src/views/settings/components/RequestLocationCard.vue
@@ -11,10 +11,10 @@ const enabled = defineModel
('enabled', { required: true })
-
+
-
+
diff --git a/frontend/src/views/settings/components/RequestQueueCard.vue b/frontend/src/views/settings/components/RequestQueueCard.vue
index a28bb5944..cbfb9ab6a 100644
--- a/frontend/src/views/settings/components/RequestQueueCard.vue
+++ b/frontend/src/views/settings/components/RequestQueueCard.vue
@@ -10,16 +10,16 @@ const concurrencyWaitTimeoutSeconds = defineModel('concurrencyWaitTimeou
-
+
-
-
+
+
-
-
+
+
-
-
+
+
diff --git a/frontend/src/views/settings/components/ResponseBodyLimitCard.vue b/frontend/src/views/settings/components/ResponseBodyLimitCard.vue
index 758dac41f..d4559b576 100644
--- a/frontend/src/views/settings/components/ResponseBodyLimitCard.vue
+++ b/frontend/src/views/settings/components/ResponseBodyLimitCard.vue
@@ -8,10 +8,10 @@ const model = defineModel({ required: true })
-
+
-
-
+
+
MiB
diff --git a/frontend/src/views/settings/components/RuntimeSettingsCard.vue b/frontend/src/views/settings/components/RuntimeSettingsCard.vue
index bfbd0dc94..fc4d04581 100644
--- a/frontend/src/views/settings/components/RuntimeSettingsCard.vue
+++ b/frontend/src/views/settings/components/RuntimeSettingsCard.vue
@@ -11,15 +11,15 @@ const requestIntervalMs = defineModel('requestIntervalMs', { required: t
-
+
@@ -29,19 +29,19 @@ const requestIntervalMs = defineModel('requestIntervalMs', { required: t
- ms
+ 毫秒
diff --git a/frontend/src/views/settings/components/SettingsAccessSection.vue b/frontend/src/views/settings/components/SettingsAccessSection.vue
new file mode 100644
index 000000000..42dbdf480
--- /dev/null
+++ b/frontend/src/views/settings/components/SettingsAccessSection.vue
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 确定要删除当前管理员 API Key 吗?此操作会立即生效
+
+
+
+
diff --git a/frontend/src/views/settings/components/TokenRefreshCard.vue b/frontend/src/views/settings/components/TokenRefreshCard.vue
index f9485b3c2..f11b64d01 100644
--- a/frontend/src/views/settings/components/TokenRefreshCard.vue
+++ b/frontend/src/views/settings/components/TokenRefreshCard.vue
@@ -10,17 +10,17 @@ const refreshConcurrency = defineModel('refreshConcurrency', { required:
-
+
-
-
+
+
-
-
+
+
diff --git a/frontend/src/views/settings/components/backup/BackupScheduleCard.vue b/frontend/src/views/settings/components/backup/BackupScheduleCard.vue
index 76b9d7b59..2a1c2a8f2 100644
--- a/frontend/src/views/settings/components/backup/BackupScheduleCard.vue
+++ b/frontend/src/views/settings/components/backup/BackupScheduleCard.vue
@@ -40,8 +40,8 @@ const TIMEZONE_OPTIONS = [
@@ -63,7 +63,7 @@ const TIMEZONE_OPTIONS = [
/>
-
+
@@ -78,13 +78,13 @@ const TIMEZONE_OPTIONS = [
-
-
+
+
diff --git a/frontend/src/views/settings/components/backup/BackupStorageCard.vue b/frontend/src/views/settings/components/backup/BackupStorageCard.vue
index 38786521e..4a25a4d59 100644
--- a/frontend/src/views/settings/components/backup/BackupStorageCard.vue
+++ b/frontend/src/views/settings/components/backup/BackupStorageCard.vue
@@ -104,7 +104,7 @@ function toggleSecretVisible(): void {
-
+
@@ -112,8 +112,8 @@ function toggleSecretVisible(): void {
-
-
+
+
@@ -161,7 +161,7 @@ function toggleSecretVisible(): void {
-
+
diff --git a/frontend/src/views/settings/components/backup/R2GuideModal.vue b/frontend/src/views/settings/components/backup/R2GuideModal.vue
index 7763d7e6a..530bcc6c2 100644
--- a/frontend/src/views/settings/components/backup/R2GuideModal.vue
+++ b/frontend/src/views/settings/components/backup/R2GuideModal.vue
@@ -18,8 +18,8 @@ const open = defineModel({ default: false })
在 Cloudflare Dashboard 进入 R2,创建一个私有存储桶(例如
- codex-proxy-backups)。
- 桶必须保持私有,不要开放公共读取。
+ codex-proxy-backups),
+ 桶必须保持私有,不要开放公共读取
@@ -33,7 +33,7 @@ const open = defineModel({ default: false })
对象写、
对象查看 与
对象删除 权限,
- 不要授予整个账号权限。
+ 不要授予整个账号权限
@@ -56,8 +56,8 @@ const open = defineModel({ default: false })
4. 完成连接测试
- 保存配置后点击「测试连接」,系统会写入一个探针对象并校验读写权限。测试通过后
- 才能启用自动计划或手动创建备份。凭据只保存在服务端,不会写入浏览器。
+ 保存配置后点击「测试连接」,系统会写入一个探针对象并校验读写权限,测试通过后
+ 才能启用自动计划或手动创建备份,凭据只保存在服务端,不会写入浏览器
diff --git a/frontend/src/views/settings/components/client-version/components/ClientCliInstallPanel.vue b/frontend/src/views/settings/components/client-version/components/ClientCliInstallPanel.vue
index ddc3b1087..eceec999b 100644
--- a/frontend/src/views/settings/components/client-version/components/ClientCliInstallPanel.vue
+++ b/frontend/src/views/settings/components/client-version/components/ClientCliInstallPanel.vue
@@ -40,7 +40,7 @@ const cliCommands = [
安装或升级 Codex CLI
- 按当前安装来源执行一种命令;官方安装器会自动选择当前平台架构。
+ 按当前安装来源执行一种命令,官方安装器会自动选择当前平台架构
@@ -53,7 +53,7 @@ const cliCommands = [
- 后端实时校验 Microsoft CDN 直链;请选择与设备一致的架构。
+ 后端实时校验 Microsoft CDN 直链,请选择与设备一致的架构
@@ -102,7 +102,7 @@ function formatFileSize(value: number | null): string {
class="m-0 rounded-cp bg-cp-error-container px-3 py-2.5 text-cp-sm leading-[1.45] font-bold text-cp-error-on-container"
role="alert"
>
- {{ error }},当前仍显示上次成功结果。
+ {{ error }},当前仍显示上次成功结果
diff --git a/frontend/src/views/settings/composables/useBackupSettings.ts b/frontend/src/views/settings/composables/useBackupSettings.ts
index 905298670..2f02eb397 100644
--- a/frontend/src/views/settings/composables/useBackupSettings.ts
+++ b/frontend/src/views/settings/composables/useBackupSettings.ts
@@ -137,7 +137,7 @@ export function useBackupSettings() {
retentionCount: Number(schedule.retentionCount) || 0,
})
applySettings(data)
- toast.success('调度配置已保存')
+ toast.success('备份计划已保存')
return true
}
catch {
diff --git a/frontend/src/views/settings/composables/useSettingsForm.ts b/frontend/src/views/settings/composables/useSettingsForm.ts
index 0e9dea4d6..de700ae4e 100644
--- a/frontend/src/views/settings/composables/useSettingsForm.ts
+++ b/frontend/src/views/settings/composables/useSettingsForm.ts
@@ -23,7 +23,6 @@ export function useSettingsForm() {
const savedRequestLocation = shallowRef
()
const form = reactive({
openaiClientProfile: null as ClientProfileSelection | null,
- disableFast: false,
requestLocationEnabled: false,
requestLocation: { country: '', region: '', city: '', timezone: '' },
refreshMarginSeconds: null as number | null,
@@ -105,7 +104,6 @@ export function useSettingsForm() {
function applySettings(data: Awaited>) {
savedRequestLocation.value = { ...data.requestLocation }
- form.disableFast = data.disableFast
form.requestLocationEnabled = data.requestLocationEnabled
form.requestLocation = { ...data.requestLocation }
form.refreshMarginSeconds = data.refreshMarginSeconds
@@ -189,7 +187,7 @@ export function useSettingsForm() {
return
const { refreshMarginSeconds, refreshConcurrency, maxConcurrentPerAccount, requestIntervalMs, rotationStrategy, maxWaitingPerKey, maxWaitingPerAccount, concurrencyWaitTimeoutSeconds, responsesMaxDecompressedBodyMiB, accountAutoFreezeThreshold, accountAutoFreezeWindowSeconds, accountAutoFreezeDurationSeconds } = form
if (refreshMarginSeconds === null || refreshConcurrency === null || maxConcurrentPerAccount === null || requestIntervalMs === null || !rotationStrategy || maxWaitingPerKey === null || maxWaitingPerAccount === null || concurrencyWaitTimeoutSeconds === null) {
- toast.warning('请完整填写运行参数和调度策略')
+ toast.warning('请完整填写并发、队列、凭据刷新参数和调度策略')
return
}
if (responsesMaxDecompressedBodyMiB === null || !Number.isInteger(responsesMaxDecompressedBodyMiB) || responsesMaxDecompressedBodyMiB < 1
@@ -199,7 +197,7 @@ export function useSettingsForm() {
}
if (![maxWaitingPerKey, maxWaitingPerAccount].every(value => Number.isInteger(value) && value >= 0 && value <= 1000)
|| !Number.isInteger(concurrencyWaitTimeoutSeconds) || concurrencyWaitTimeoutSeconds < 1 || concurrencyWaitTimeoutSeconds > 120) {
- toast.warning('最大排队数应为 0~1000 的整数,最长排队时间应为 1~120 秒的整数')
+ toast.warning('队列容量应为 0~1000 的整数,排队超时应为 1~120 秒的整数')
return
}
if (minCodexDesktopVersionError.value || minCodexCliVersionError.value) {
@@ -216,13 +214,13 @@ export function useSettingsForm() {
return
}
if (accountAutoFreezeThreshold === null || accountAutoFreezeWindowSeconds === null || accountAutoFreezeDurationSeconds === null) {
- toast.warning('请完整填写账号自动冻结参数')
+ toast.warning('请完整填写过载保护参数')
return
}
if (!Number.isInteger(accountAutoFreezeThreshold) || accountAutoFreezeThreshold < 2 || accountAutoFreezeThreshold > 1000
|| !Number.isInteger(accountAutoFreezeWindowSeconds) || accountAutoFreezeWindowSeconds < 60 || accountAutoFreezeWindowSeconds > 3600
|| !Number.isInteger(accountAutoFreezeDurationSeconds) || accountAutoFreezeDurationSeconds < 300 || accountAutoFreezeDurationSeconds > 604800) {
- toast.warning('自动冻结阈值应为 2~1000,统计窗口为 60~3600 秒,冻结时长为 300~604800 秒')
+ toast.warning('失败次数阈值应为 2~1000,统计窗口为 60~3600 秒,冷却时长为 300~604800 秒')
return
}
const probeModel = form.accountAutoFreezeProbeModel.trim()
@@ -234,7 +232,6 @@ export function useSettingsForm() {
await saveAction.run(async () => {
const result = await updateSettings({
openaiClientProfile,
- disableFast: form.disableFast,
requestLocationEnabled: form.requestLocationEnabled,
requestLocation,
modelMappings: mappingPayload(),
diff --git a/frontend/src/views/settings/constants.ts b/frontend/src/views/settings/constants.ts
index fc6a734e2..353392ff7 100644
--- a/frontend/src/views/settings/constants.ts
+++ b/frontend/src/views/settings/constants.ts
@@ -10,12 +10,12 @@ export const rotationOptions = [
description: '优先选择额度窗口更快重置的账号,适合在重置前消耗剩余额度',
},
{
- label: '轮询',
+ label: '轮询调度',
value: 'round_robin',
description: '在可用候选账号间按顺序轮转,分配结果最可预测',
},
{
- label: '粘滞',
+ label: '粘性调度',
value: 'sticky',
description: '优先复用最近使用的账号,直到不可用后再切换',
},
diff --git a/frontend/src/views/settings/index.vue b/frontend/src/views/settings/index.vue
index 3c0364550..5fcd2a6c8 100644
--- a/frontend/src/views/settings/index.vue
+++ b/frontend/src/views/settings/index.vue
@@ -1,38 +1,50 @@
-
+
-
+
+
-
+
- {{ saving ? '保存中...' : '保存' }}
+ {{ saving ? '保存中...' : '保存基础设置' }}
- {{ hasChanges ? '有未保存更改' : '' }}
-
-
+
设置加载失败:{{ error }}
@@ -138,81 +136,59 @@ watch(section, (value) => {
正在加载设置…
-
-
-
-
-
-
-
-
- 确定要删除当前管理员 API Key 吗?此操作会立即生效
-
-
+
+
diff --git a/frontend/src/views/settings/pricing/PricingBatchModal.vue b/frontend/src/views/settings/pricing/PricingBatchModal.vue
index f501610d1..9ea085d83 100644
--- a/frontend/src/views/settings/pricing/PricingBatchModal.vue
+++ b/frontend/src/views/settings/pricing/PricingBatchModal.vue
@@ -6,6 +6,7 @@ import BaseButton from '@/components/base/BaseButton.vue'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
+import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import { multiplierText, parseMultiplier } from './model'
defineProps<{ rows: PricingRow[], reset: boolean, saving: boolean }>()
@@ -20,26 +21,28 @@ watch(open, (value) => {
-
+
- 将移除选中模型的人工单价和倍率,回到同步价目或内置价目。仅有人工价格的模型将恢复为未配置。
+ 恢复来源或内置价格,无可用价格时设为未配置
-
+
×
-
- -
- {{ row.model }}
- {{ multiplierText(row.effective.multiplierBps) }} → {{ reset ? '1×' : bps === undefined ? '—' : multiplierText(bps) }}
-
-
+
+
+ -
+ {{ row.model }}
+ {{ multiplierText(row.effective.multiplierBps) }} → {{ reset ? '1×' : bps === undefined ? '—' : multiplierText(bps) }}
+
+
+
- 请输入合法倍率。
+ 请输入合法倍率
diff --git a/frontend/src/views/settings/pricing/PricingEditor.vue b/frontend/src/views/settings/pricing/PricingEditor.vue
index 412412041..6a600c3e7 100644
--- a/frontend/src/views/settings/pricing/PricingEditor.vue
+++ b/frontend/src/views/settings/pricing/PricingEditor.vue
@@ -67,22 +67,22 @@ function updatePrice(field: keyof TokenPrices, value: string) {
}
function submit() {
if (!model.value || new TextEncoder().encode(model.value).length > 128 || /\s/.test(model.value) || [...model.value].some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) {
- validation.value = '模型 ID 必须为 1~128 字节,不含空白或控制字符。'
+ validation.value = '模型 ID 必须为 1~128 字节,不含空白或控制字符'
return
}
if (bps.value === undefined) {
- validation.value = '倍率范围为 0~100,最多四位小数。'
+ validation.value = '倍率范围为 0~100,最多四位小数'
return
}
for (const [band, rates] of Object.entries(draft.value)) {
if (Object.values(rates).some(price => !validPrice(price))) {
- validation.value = '请完整填写该档位的四项单价:0~1000000,最多四位小数。'
+ validation.value = '请完整填写该档位的四项单价:0~1000000,最多四位小数'
selectedBand.value = band as PriceBand
return
}
}
if (!props.row?.base.bands.standard && !draft.value.standard) {
- validation.value = '新模型需要配置标准档单价。'
+ validation.value = '新模型需要配置标准档单价'
selectedBand.value = 'standard'
return
}
@@ -141,13 +141,13 @@ function submit() {
- 模型 ID 使用实际发送给上游的名称,不是客户端别名。
+ 模型 ID 使用实际发送给上游的名称,不是客户端别名
- 自定义档位需填写四项单价,0 表示免费;单价上限 1000000,倍率范围 0~100,最多四位小数。
+ 自定义档位需填写四项单价,0 表示免费,单价上限 1000000,倍率范围 0~100,最多四位小数
- 仅调整本地估算与金额限额,不代表订阅实际扣费。保存后仅新请求生效,历史账单不变。
+ 仅调整本地估算与金额限额,不代表订阅实际扣费,保存后仅新请求生效,历史账单不变
diff --git a/frontend/src/views/settings/pricing/PricingSyncModal.vue b/frontend/src/views/settings/pricing/PricingSyncModal.vue
index 894e0c9e8..e8f89c048 100644
--- a/frontend/src/views/settings/pricing/PricingSyncModal.vue
+++ b/frontend/src/views/settings/pricing/PricingSyncModal.vue
@@ -1,16 +1,18 @@
-
+
- {{ changes.length }} 个模型的来源价格将变化。人工单价和倍率保留;未覆盖的档位会采用新的来源价格。models.dev 是社区价目,不代表订阅实际扣费。
+ 仅同步所选模型的来源价格,保留自定义单价和倍率
-
-
-
-
- {{ item.id }}
-
保留人工覆盖
-
-
- {{ detail.label }}
-
- {{ detail.before }}→{{ detail.after }}
-
-
-
-
- 来源价格没有变化。
-
+
+ item.id) : [])"
+ />
+ 已选 {{ selectedChanges.length }} / {{ changes.length }}
+
+
+
+
+
+ {{ item.id }}
+
+
+
保留人工覆盖
+
+
+ {{ detail.label }}
+
+ {{ detail.before }}→{{ detail.after }}
+
+
+
+
+
+ 来源价格没有变化
+
+
+
跳过 {{ preview.skipped.length }} 个缺少完整价格或计价方式不匹配的模型
@@ -81,7 +130,7 @@ const changes = computed(() => {
取消
-
+
确认同步
diff --git a/frontend/src/views/settings/pricing/PricingTable.vue b/frontend/src/views/settings/pricing/PricingTable.vue
index b022d81d1..c4856795b 100644
--- a/frontend/src/views/settings/pricing/PricingTable.vue
+++ b/frontend/src/views/settings/pricing/PricingTable.vue
@@ -1,13 +1,15 @@
@@ -57,7 +63,7 @@ async function saveBatch(change: PricingChange) {
@reset="batch(true)"
@clear="selected = []"
/>
-
+
@@ -70,6 +76,9 @@ async function saveBatch(change: PricingChange) {
+
+ {{ deleting?.model }}
+
diff --git a/frontend/src/views/settings/pricing/model.ts b/frontend/src/views/settings/pricing/model.ts
index e89df50fb..a21f0ed01 100644
--- a/frontend/src/views/settings/pricing/model.ts
+++ b/frontend/src/views/settings/pricing/model.ts
@@ -19,6 +19,7 @@ export interface PricingRow {
model: string
provider: string
source: 'builtin' | 'synced' | 'custom'
+ canDelete: boolean
base: ModelPricing
custom?: ModelPricing
effective: ModelPricing
@@ -36,6 +37,7 @@ export function pricingRows(catalog: PricingCatalog, provider: string): PricingR
base,
custom: override,
source: override ? 'custom' : synced[model] ? 'synced' : 'builtin',
+ canDelete: !Object.hasOwn(defaults, model),
effective: { multiplierBps: override?.multiplierBps ?? 10_000, bands: { ...base.bands, ...override?.bands } },
}
})
diff --git a/frontend/src/views/settings/pricing/usePricing.ts b/frontend/src/views/settings/pricing/usePricing.ts
index f266c0d54..09c4d6509 100644
--- a/frontend/src/views/settings/pricing/usePricing.ts
+++ b/frontend/src/views/settings/pricing/usePricing.ts
@@ -42,7 +42,7 @@ export function usePricing() {
async function save(models: string[], change: PricingChange): Promise {
return await writeAction.run(async () => {
await updatePricing({ provider: provider.value, models, change })
- toast.success(change.action === 'reset' ? '已清除人工覆盖' : '模型定价已保存')
+ toast.success(change.action === 'delete' ? '模型价目已删除' : change.action === 'reset' ? '已清除人工覆盖' : '模型定价已保存')
selected.value = []
await load()
return true
@@ -53,12 +53,12 @@ export function usePricing() {
preview.value = await previewPricingSync()
})
}
- async function confirmSync() {
- if (!preview.value)
+ async function confirmSync(models: Record) {
+ if (!preview.value || !Object.values(models).some(items => items.length))
return
const approved = preview.value
await writeAction.run(async () => {
- await syncPricing(approved)
+ await syncPricing({ preview: approved, models })
preview.value = undefined
toast.success('来源价目已同步,人工覆盖保持不变')
await load()
diff --git a/frontend/src/views/theme/components/ThemeComponentPreview.vue b/frontend/src/views/theme/components/ThemeComponentPreview.vue
index 9290e2ee0..84fddaa67 100644
--- a/frontend/src/views/theme/components/ThemeComponentPreview.vue
+++ b/frontend/src/views/theme/components/ThemeComponentPreview.vue
@@ -52,7 +52,7 @@ interface PreviewTableRow {
}
const search = shallowRef('中继账号')
-const notes = shallowRef('优先保持当前账号,额度耗尽后再切换。')
+const notes = shallowRef('优先保持当前账号,额度耗尽后再切换')
const brandColor = shallowRef('#5983F4')
const invalidKey = shallowRef('sk-invalid')
const disabledText = shallowRef('不可编辑')
@@ -382,7 +382,7 @@ function statusClass(status: PreviewTableRow['status']) {
-
+
添加账号
diff --git a/frontend/src/views/theme/components/ThemeComponentTokenPanel.vue b/frontend/src/views/theme/components/ThemeComponentTokenPanel.vue
index 0fb106c82..6f56efa51 100644
--- a/frontend/src/views/theme/components/ThemeComponentTokenPanel.vue
+++ b/frontend/src/views/theme/components/ThemeComponentTokenPanel.vue
@@ -173,7 +173,7 @@ function numericTokenValue(name: ThemeTokenName) {
- 调整 {{ activeComponent.label }} 的 Component Token;未修改的值继续由全局 Seed / Alias 算法生成。
+ 调整 {{ activeComponent.label }} 的 Component Token,未修改的值继续由全局 Seed / Alias 算法生成
diff --git a/frontend/src/views/theme/components/ThemeGlobalTokenPanel.vue b/frontend/src/views/theme/components/ThemeGlobalTokenPanel.vue
index a80907571..414252181 100644
--- a/frontend/src/views/theme/components/ThemeGlobalTokenPanel.vue
+++ b/frontend/src/views/theme/components/ThemeGlobalTokenPanel.vue
@@ -290,7 +290,7 @@ function presetSwatches(preset: ThemeColorPreset) {
- 固定无边设计;圆角塑形,阴影分层。
+ 固定无边设计,圆角塑形,阴影分层
- {{ error }}。请刷新重试。
+ {{ error }},请刷新重试
{
- 这条记录未保存底层错误分类;需用请求 ID 检索留存的服务器日志。
+ 这条记录未保存底层错误分类,需用请求 ID 检索留存的服务器日志
diff --git a/frontend/src/views/usage/components/UsageDiagnosticCard.vue b/frontend/src/views/usage/components/UsageDiagnosticCard.vue
index 3e55c43a7..b30fa8a5a 100644
--- a/frontend/src/views/usage/components/UsageDiagnosticCard.vue
+++ b/frontend/src/views/usage/components/UsageDiagnosticCard.vue
@@ -196,7 +196,7 @@ function diagnosticNameDisplay(name: string) {
{{ formatCompactNumber(row.errorCount) }}
diff --git a/frontend/src/views/usage/components/UsageLatencyCell.vue b/frontend/src/views/usage/components/UsageLatencyCell.vue
index 07cdd5bc3..8029cbffe 100644
--- a/frontend/src/views/usage/components/UsageLatencyCell.vue
+++ b/frontend/src/views/usage/components/UsageLatencyCell.vue
@@ -40,7 +40,7 @@ const latencyDetails = computed(() => usageLatencyDetails(props.record))
- 此记录未采集完整的阶段耗时。
+ 此记录未采集完整的阶段耗时
@@ -68,7 +68,7 @@ const latencyDetails = computed(() => usageLatencyDetails(props.record))
- 与阶段耗时可能重叠,不参与总耗时相加。
+ 与阶段耗时可能重叠,不参与总耗时相加
diff --git a/frontend/src/views/usage/constants.ts b/frontend/src/views/usage/constants.ts
index 0bcdd4be5..913bf4332 100644
--- a/frontend/src/views/usage/constants.ts
+++ b/frontend/src/views/usage/constants.ts
@@ -20,8 +20,8 @@ export const usageRecordColumns = defineTableColumns
([
size: 'sm',
format: (value: unknown) => formatProvider(typeof value === 'string' ? value : null),
},
- { key: 'model', label: '模型', kind: 'custom', size: 'xl' },
- { key: 'reasoningEffort', label: '推理强度', kind: 'status', size: 'lg' },
+ { key: 'model', label: '模型', kind: 'custom', size: 'lg' },
+ { key: 'reasoningEffort', label: '推理强度', kind: 'status', size: 'md', align: 'left' },
{ key: 'route', label: '端点', kind: 'mono' },
{ key: 'upstreamTransport', label: '上游', kind: 'status', size: 'md' },
{ key: 'clientTransport', label: '接入', kind: 'status', size: 'md' },
diff --git a/frontend/src/views/usage/utils/diagnosticsBundle.ts b/frontend/src/views/usage/utils/diagnosticsBundle.ts
index 4f66cab26..7185273a4 100644
--- a/frontend/src/views/usage/utils/diagnosticsBundle.ts
+++ b/frontend/src/views/usage/utils/diagnosticsBundle.ts
@@ -119,12 +119,12 @@ export function requestDiagnosticsBundle(
'client_ip_user_agent_and_api_key',
],
notes: [
- 'schemaVersion 2 仅供人工排障;null 表示未知或未采集,不代表没有发生错误。',
- '错误摘要复用列表的分类展示,不是上游错误原文;error 为 null 时请结合 attempts 的分类与状态。',
- '时间线仅导出阶段、顺序和计时,不含事件 data;历史 sanitized 标记不作为安全依据。',
- 'attemptsComplete 不为 true 时,尝试列表不完整;没有时间线的旧记录无法补回未采集事件。',
- '未自动采集网关版本、客户端版本、部署环境和故障发生时区,请另行补充。',
- '关联 ID 仍可能属于内部信息,分享前请审阅;原始错误、正文和日志需另行审阅脱敏,勿直接公开。',
+ 'schemaVersion 2 仅供人工排障,null 表示未知或未采集,不代表没有发生错误',
+ '错误摘要复用列表的分类展示,不是上游错误原文,error 为 null 时请结合 attempts 的分类与状态',
+ '时间线仅导出阶段、顺序和计时,不含事件 data,历史 sanitized 标记不作为安全依据',
+ 'attemptsComplete 不为 true 时,尝试列表不完整,没有时间线的旧记录无法补回未采集事件',
+ '未自动采集网关版本、客户端版本、部署环境和故障发生时区,请另行补充',
+ '关联 ID 仍可能属于内部信息,分享前请审阅,原始错误、正文和日志需另行审阅脱敏,勿直接公开',
],
}
}