From dd2958a458a582d77803a8e57c9aa3f23672dd7b Mon Sep 17 00:00:00 2001
From: ZheFox <77232781+zhefox@users.noreply.github.com>
Date: Fri, 28 Aug 2026 12:47:54 +0800
Subject: [PATCH] fix(admin): persist provider settings and enforce quotas
---
.../adapters/mysql/src/settlement.rs | 25 +--
.../adapters/postgres/src/settlement.rs | 25 +--
.../adapters/sqlite/src/settlement.rs | 148 +++++++++++++++---
.../components/EndpointFormDialog.vue | 6 +-
.../providers/components/KeyFormDialog.vue | 10 +-
.../components/OAuthKeyEditDialog.vue | 6 +-
.../components/ProviderDetailDrawer.vue | 15 +-
.../endpoint-form-dialog-layout.spec.ts | 25 +++
.../provider-key-concurrent_limit.spec.ts | 5 +
9 files changed, 197 insertions(+), 68 deletions(-)
create mode 100644 frontend/src/features/providers/components/__tests__/endpoint-form-dialog-layout.spec.ts
diff --git a/crates/aether-data/adapters/mysql/src/settlement.rs b/crates/aether-data/adapters/mysql/src/settlement.rs
index eaab36c9f..02e0d8e93 100644
--- a/crates/aether-data/adapters/mysql/src/settlement.rs
+++ b/crates/aether-data/adapters/mysql/src/settlement.rs
@@ -312,23 +312,12 @@ WHERE user_entitlement_id = ?
total_remaining += remaining;
grants_with_remaining.push((grant, remaining));
}
- if !allow_wallet_overage && total_remaining + 0.000_000_01 < total_cost_usd {
- return Ok(DailyQuotaDebitResult {
- debited_usd: 0.0,
- insufficient: true,
- });
- }
- if allow_wallet_overage
- && !wallet_can_overdraft
- && wallet_available_usd.is_some_and(|available| {
- total_remaining + available + SETTLEMENT_EPSILON_USD < total_cost_usd
- })
- {
- return Ok(DailyQuotaDebitResult {
- debited_usd: 0.0,
- insufficient: true,
- });
- }
+ let insufficient = (!allow_wallet_overage && total_remaining + 0.000_000_01 < total_cost_usd)
+ || (allow_wallet_overage
+ && !wallet_can_overdraft
+ && wallet_available_usd.is_some_and(|available| {
+ total_remaining + available + SETTLEMENT_EPSILON_USD < total_cost_usd
+ }));
let mut remaining_cost = total_cost_usd;
let mut debited = 0.0;
@@ -364,7 +353,7 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
}
Ok(DailyQuotaDebitResult {
debited_usd: debited,
- insufficient: false,
+ insufficient,
})
}
diff --git a/crates/aether-data/adapters/postgres/src/settlement.rs b/crates/aether-data/adapters/postgres/src/settlement.rs
index 285815ccd..f594c0413 100644
--- a/crates/aether-data/adapters/postgres/src/settlement.rs
+++ b/crates/aether-data/adapters/postgres/src/settlement.rs
@@ -374,23 +374,12 @@ WHERE user_entitlement_id = $1
grants_with_remaining.push((grant, remaining));
}
- if !allow_wallet_overage && total_remaining + 0.000_000_01 < total_cost_usd {
- return Ok(DailyQuotaDebitResult {
- debited_usd: 0.0,
- insufficient: true,
- });
- }
- if allow_wallet_overage
- && !wallet_can_overdraft
- && wallet_available_usd.is_some_and(|available| {
- total_remaining + available + SETTLEMENT_EPSILON_USD < total_cost_usd
- })
- {
- return Ok(DailyQuotaDebitResult {
- debited_usd: 0.0,
- insufficient: true,
- });
- }
+ let insufficient = (!allow_wallet_overage && total_remaining + 0.000_000_01 < total_cost_usd)
+ || (allow_wallet_overage
+ && !wallet_can_overdraft
+ && wallet_available_usd.is_some_and(|available| {
+ total_remaining + available + SETTLEMENT_EPSILON_USD < total_cost_usd
+ }));
let mut remaining_cost = total_cost_usd;
let mut debited = 0.0;
@@ -426,7 +415,7 @@ ON CONFLICT (user_entitlement_id, request_id) DO NOTHING
}
Ok(DailyQuotaDebitResult {
debited_usd: debited,
- insufficient: false,
+ insufficient,
})
}
diff --git a/crates/aether-data/adapters/sqlite/src/settlement.rs b/crates/aether-data/adapters/sqlite/src/settlement.rs
index dd00095f2..ef77b96d5 100644
--- a/crates/aether-data/adapters/sqlite/src/settlement.rs
+++ b/crates/aether-data/adapters/sqlite/src/settlement.rs
@@ -325,23 +325,12 @@ WHERE user_entitlement_id = ?
total_remaining += remaining;
grants_with_remaining.push((grant, remaining));
}
- if !allow_wallet_overage && total_remaining + 0.000_000_01 < total_cost_usd {
- return Ok(DailyQuotaDebitResult {
- debited_usd: 0.0,
- insufficient: true,
- });
- }
- if allow_wallet_overage
- && !wallet_can_overdraft
- && wallet_available_usd.is_some_and(|available| {
- total_remaining + available + SETTLEMENT_EPSILON_USD < total_cost_usd
- })
- {
- return Ok(DailyQuotaDebitResult {
- debited_usd: 0.0,
- insufficient: true,
- });
- }
+ let insufficient = (!allow_wallet_overage && total_remaining + 0.000_000_01 < total_cost_usd)
+ || (allow_wallet_overage
+ && !wallet_can_overdraft
+ && wallet_available_usd.is_some_and(|available| {
+ total_remaining + available + SETTLEMENT_EPSILON_USD < total_cost_usd
+ }));
let mut remaining_cost = total_cost_usd;
let mut debited = 0.0;
@@ -377,7 +366,7 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
}
Ok(DailyQuotaDebitResult {
debited_usd: debited,
- insufficient: false,
+ insufficient,
})
}
@@ -983,6 +972,108 @@ WHERE request_id = 'request-1'
assert_eq!(quota_used, 6.0);
}
+ #[tokio::test]
+ async fn sqlite_repository_exhausts_strict_quota_after_actual_cost_overrun() {
+ let pool = sqlx::sqlite::SqlitePoolOptions::new()
+ .max_connections(1)
+ .connect("sqlite::memory:")
+ .await
+ .expect("sqlite pool should connect");
+ run_migrations(&pool)
+ .await
+ .expect("sqlite migrations should run");
+ seed_quota_covered_settlement_rows(&pool).await;
+
+ let repository = SqliteSettlementRepository::new(pool.clone());
+ let settlement = repository
+ .settle_usage(UsageSettlementInput {
+ request_id: "request-quota-overrun".to_string(),
+ user_id: Some("user-quota".to_string()),
+ api_key_id: Some("key-quota".to_string()),
+ api_key_is_standalone: false,
+ provider_id: None,
+ status: "completed".to_string(),
+ billing_status: "pending".to_string(),
+ total_cost_usd: 12.0,
+ actual_total_cost_usd: 12.0,
+ finalized_at_unix_secs: Some(1_261),
+ })
+ .await
+ .expect("settlement should run")
+ .expect("usage should exist");
+
+ assert_eq!(settlement.billing_status, "insufficient_quota");
+ let quota_used: f64 = sqlx::query_scalar(
+ "SELECT CAST(COALESCE(SUM(amount_usd), 0) AS REAL) FROM entitlement_usage_ledgers WHERE request_id = 'request-quota-overrun'",
+ )
+ .fetch_one(&pool)
+ .await
+ .expect("quota ledger should load");
+ assert_eq!(quota_used, 10.0);
+ }
+
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn sqlite_repository_exhausts_strict_quota_across_concurrent_requests() {
+ let database_path = std::env::temp_dir().join(format!(
+ "aether-quota-settlement-{}.db",
+ uuid::Uuid::new_v4()
+ ));
+ let options = sqlx::sqlite::SqliteConnectOptions::new()
+ .filename(&database_path)
+ .create_if_missing(true)
+ .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
+ .busy_timeout(Duration::from_secs(5));
+ let pool = sqlx::sqlite::SqlitePoolOptions::new()
+ .max_connections(2)
+ .connect_with(options)
+ .await
+ .expect("sqlite pool should connect");
+ run_migrations(&pool)
+ .await
+ .expect("sqlite migrations should run");
+ seed_quota_covered_settlement_rows(&pool).await;
+
+ let repository = SqliteSettlementRepository::new(pool.clone());
+ let input = |request_id: &str| UsageSettlementInput {
+ request_id: request_id.to_string(),
+ user_id: Some("user-quota".to_string()),
+ api_key_id: Some("key-quota".to_string()),
+ api_key_is_standalone: false,
+ provider_id: None,
+ status: "completed".to_string(),
+ billing_status: "pending".to_string(),
+ total_cost_usd: 6.0,
+ actual_total_cost_usd: 6.0,
+ finalized_at_unix_secs: Some(1_262),
+ };
+ let (first, second) = tokio::join!(
+ repository.settle_usage(input("request-quota-race-1")),
+ repository.settle_usage(input("request-quota-race-2")),
+ );
+ let first = first
+ .expect("first settlement should succeed")
+ .expect("first usage should exist");
+ let second = second
+ .expect("second settlement should succeed")
+ .expect("second usage should exist");
+ let mut statuses = [first.billing_status, second.billing_status];
+ statuses.sort();
+ assert_eq!(statuses, ["insufficient_quota", "settled"]);
+
+ let quota_used: f64 = sqlx::query_scalar(
+ "SELECT CAST(COALESCE(SUM(amount_usd), 0) AS REAL) FROM entitlement_usage_ledgers",
+ )
+ .fetch_one(&pool)
+ .await
+ .expect("quota ledger should load");
+ assert_eq!(quota_used, 10.0);
+
+ pool.close().await;
+ let _ = std::fs::remove_file(&database_path);
+ let _ = std::fs::remove_file(format!("{}-wal", database_path.display()));
+ let _ = std::fs::remove_file(format!("{}-shm", database_path.display()));
+ }
+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn sqlite_repository_serializes_concurrent_settlement_attempts() {
let database_path = std::env::temp_dir().join(format!(
@@ -1098,10 +1189,23 @@ INSERT INTO wallets (
INSERT INTO "usage" (
request_id, user_id, api_key_id, status, billing_status,
total_cost_usd, actual_total_cost_usd
-) VALUES (
- 'request-quota-covered', 'user-quota', 'key-quota', 'completed',
- 'pending', 3.0, 6.0
-);
+) VALUES
+ (
+ 'request-quota-covered', 'user-quota', 'key-quota', 'completed',
+ 'pending', 3.0, 6.0
+ ),
+ (
+ 'request-quota-overrun', 'user-quota', 'key-quota', 'completed',
+ 'pending', 12.0, 12.0
+ ),
+ (
+ 'request-quota-race-1', 'user-quota', 'key-quota', 'completed',
+ 'pending', 6.0, 6.0
+ ),
+ (
+ 'request-quota-race-2', 'user-quota', 'key-quota', 'completed',
+ 'pending', 6.0, 6.0
+ );
INSERT INTO billing_plans (
id, title, price_amount, price_currency, duration_unit,
diff --git a/frontend/src/features/providers/components/EndpointFormDialog.vue b/frontend/src/features/providers/components/EndpointFormDialog.vue
index a1dc646a2..73f7481b3 100644
--- a/frontend/src/features/providers/components/EndpointFormDialog.vue
+++ b/frontend/src/features/providers/components/EndpointFormDialog.vue
@@ -941,7 +941,11 @@
-
+
()
const { success, error: showError } = useToast()
@@ -1001,11 +1001,12 @@ async function handleSave() {
updateData.auth_config = authConfig
}
- await updateProviderKey(props.editingKey.id, updateData)
+ const updatedKey = await updateProviderKey(props.editingKey.id, updateData)
success(legacyT('密钥已更新'), legacyT('成功'))
+ emit('saved', updatedKey)
} else {
// 新增模式
- await addProviderKey(props.providerId, {
+ const createdKey = await addProviderKey(props.providerId, {
api_formats: form.value.api_formats,
api_key: form.value.api_key,
auth_type: form.value.auth_type,
@@ -1027,12 +1028,11 @@ async function handleSave() {
success(legacyT('密钥已添加'), legacyT('成功'))
// 添加模式:不关闭对话框,只清除名称和密钥以便继续添加
- emit('saved')
+ emit('saved', createdKey)
clearForNextAdd()
return
}
- emit('saved')
emit('close')
} catch (err: unknown) {
const errorMessage = parseApiError(err, legacyT('保存密钥失败'))
diff --git a/frontend/src/features/providers/components/OAuthKeyEditDialog.vue b/frontend/src/features/providers/components/OAuthKeyEditDialog.vue
index 49948d018..da0b31eda 100644
--- a/frontend/src/features/providers/components/OAuthKeyEditDialog.vue
+++ b/frontend/src/features/providers/components/OAuthKeyEditDialog.vue
@@ -217,7 +217,7 @@ const props = defineProps<{
const emit = defineEmits<{
close: []
- saved: []
+ saved: [key: EndpointAPIKey]
}>()
const { success, error: showError } = useToast()
@@ -390,9 +390,9 @@ async function handleSave() {
model_exclude_patterns: parsePatternText(form.value.model_exclude_patterns_text)
}
- await updateProviderKey(props.editingKey.id, updateData)
+ const updatedKey = await updateProviderKey(props.editingKey.id, updateData)
success('账号已更新', '成功')
- emit('saved')
+ emit('saved', updatedKey)
emit('close')
} catch (err: unknown) {
const errorMessage = parseApiError(err, '保存失败')
diff --git a/frontend/src/features/providers/components/ProviderDetailDrawer.vue b/frontend/src/features/providers/components/ProviderDetailDrawer.vue
index 290de2b95..faacfd3fb 100644
--- a/frontend/src/features/providers/components/ProviderDetailDrawer.vue
+++ b/frontend/src/features/providers/components/ProviderDetailDrawer.vue
@@ -2896,8 +2896,21 @@ async function openAntigravityQuotaDialog(key: EndpointAPIKey) {
}
}
-async function handleKeyChanged() {
+function applyUpdatedKeySnapshot(updatedKey: EndpointAPIKey) {
+ const index = providerKeys.value.findIndex(key => key.id === updatedKey.id)
+ if (index >= 0) {
+ providerKeys.value.splice(index, 1, updatedKey)
+ }
+ if (editingKey.value?.id === updatedKey.id) {
+ editingKey.value = updatedKey
+ }
+ syncCurrentSelections(endpoints.value, providerKeys.value)
+}
+
+async function handleKeyChanged(updatedKey?: EndpointAPIKey) {
+ if (updatedKey) applyUpdatedKeySnapshot(updatedKey)
await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
+ if (updatedKey) applyUpdatedKeySnapshot(updatedKey)
emit('refresh')
// 添加/修改 key 后自动获取已支持 provider 的配额(新 key 的 upstream_metadata 为空)
void autoRefreshQuotaInBackground().then((changed) => {
diff --git a/frontend/src/features/providers/components/__tests__/endpoint-form-dialog-layout.spec.ts b/frontend/src/features/providers/components/__tests__/endpoint-form-dialog-layout.spec.ts
new file mode 100644
index 000000000..f1aea04d8
--- /dev/null
+++ b/frontend/src/features/providers/components/__tests__/endpoint-form-dialog-layout.spec.ts
@@ -0,0 +1,25 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+function readSource(path: string): string {
+ return readFileSync(resolve(process.cwd(), path), 'utf8')
+}
+
+describe('endpoint form dialog layout', () => {
+ it('portals the format selector outside the clipped dialog', () => {
+ const source = readSource('src/features/providers/components/EndpointFormDialog.vue')
+ const modelIndex = source.indexOf('v-model="newEndpoint.api_format"')
+ const selectStart = source.lastIndexOf('