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('', modelIndex), + ) + + expect(modelIndex).toBeGreaterThan(-1) + expect(selectStart).toBeGreaterThan(-1) + expect(formatSelector).toContain(':disable-portal="false"') + expect(formatSelector).toContain('var(--radix-select-content-available-height)') + expect(formatSelector).toContain('var(--radix-select-trigger-width)') + }) +}) \ No newline at end of file diff --git a/frontend/src/features/providers/components/__tests__/provider-key-concurrent_limit.spec.ts b/frontend/src/features/providers/components/__tests__/provider-key-concurrent_limit.spec.ts index afd92bff3..3a5646030 100644 --- a/frontend/src/features/providers/components/__tests__/provider-key-concurrent_limit.spec.ts +++ b/frontend/src/features/providers/components/__tests__/provider-key-concurrent_limit.spec.ts @@ -372,6 +372,9 @@ describe('provider key concurrent_limit form behavior', () => { }) it('hydrates and serializes a positive concurrent_limit number from the normal key form', async () => { + const saved = vi.fn() + const updatedKey = createProviderKey({ rpm_limit: 42, concurrent_limit: 5 }) + endpointMocks.updateProviderKey.mockResolvedValue(updatedKey) const root = mountDialog(KeyFormDialog, { open: true, endpoint: null, @@ -379,6 +382,7 @@ describe('provider key concurrent_limit form behavior', () => { providerId: 'provider-1', providerType: 'openai', availableApiFormats: ['openai:chat'], + onSaved: saved, }) await settle() @@ -394,6 +398,7 @@ describe('provider key concurrent_limit form behavior', () => { expect(typeof payload.concurrent_limit).toBe('number') expect(payload.concurrent_limit).not.toBe('') expect(payload.rpm_limit).toBe(42) + expect(saved).toHaveBeenCalledWith(updatedKey) }) it('serializes cleared normal key concurrent_limit as null instead of an empty string', async () => {