fix(admin): persist provider settings and enforce quotas

This commit is contained in:
ZheFox
2026-08-28 12:47:54 +08:00
parent c4b4dfa996
commit dd2958a458
9 changed files with 197 additions and 68 deletions
@@ -312,23 +312,12 @@ WHERE user_entitlement_id = ?
total_remaining += remaining; total_remaining += remaining;
grants_with_remaining.push((grant, remaining)); grants_with_remaining.push((grant, remaining));
} }
if !allow_wallet_overage && total_remaining + 0.000_000_01 < total_cost_usd { let insufficient = (!allow_wallet_overage && total_remaining + 0.000_000_01 < total_cost_usd)
return Ok(DailyQuotaDebitResult { || (allow_wallet_overage
debited_usd: 0.0, && !wallet_can_overdraft
insufficient: true, && wallet_available_usd.is_some_and(|available| {
}); total_remaining + available + SETTLEMENT_EPSILON_USD < total_cost_usd
} }));
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 mut remaining_cost = total_cost_usd; let mut remaining_cost = total_cost_usd;
let mut debited = 0.0; let mut debited = 0.0;
@@ -364,7 +353,7 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
} }
Ok(DailyQuotaDebitResult { Ok(DailyQuotaDebitResult {
debited_usd: debited, debited_usd: debited,
insufficient: false, insufficient,
}) })
} }
@@ -374,23 +374,12 @@ WHERE user_entitlement_id = $1
grants_with_remaining.push((grant, remaining)); grants_with_remaining.push((grant, remaining));
} }
if !allow_wallet_overage && total_remaining + 0.000_000_01 < total_cost_usd { let insufficient = (!allow_wallet_overage && total_remaining + 0.000_000_01 < total_cost_usd)
return Ok(DailyQuotaDebitResult { || (allow_wallet_overage
debited_usd: 0.0, && !wallet_can_overdraft
insufficient: true, && wallet_available_usd.is_some_and(|available| {
}); total_remaining + available + SETTLEMENT_EPSILON_USD < total_cost_usd
} }));
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 mut remaining_cost = total_cost_usd; let mut remaining_cost = total_cost_usd;
let mut debited = 0.0; let mut debited = 0.0;
@@ -426,7 +415,7 @@ ON CONFLICT (user_entitlement_id, request_id) DO NOTHING
} }
Ok(DailyQuotaDebitResult { Ok(DailyQuotaDebitResult {
debited_usd: debited, debited_usd: debited,
insufficient: false, insufficient,
}) })
} }
@@ -325,23 +325,12 @@ WHERE user_entitlement_id = ?
total_remaining += remaining; total_remaining += remaining;
grants_with_remaining.push((grant, remaining)); grants_with_remaining.push((grant, remaining));
} }
if !allow_wallet_overage && total_remaining + 0.000_000_01 < total_cost_usd { let insufficient = (!allow_wallet_overage && total_remaining + 0.000_000_01 < total_cost_usd)
return Ok(DailyQuotaDebitResult { || (allow_wallet_overage
debited_usd: 0.0, && !wallet_can_overdraft
insufficient: true, && wallet_available_usd.is_some_and(|available| {
}); total_remaining + available + SETTLEMENT_EPSILON_USD < total_cost_usd
} }));
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 mut remaining_cost = total_cost_usd; let mut remaining_cost = total_cost_usd;
let mut debited = 0.0; let mut debited = 0.0;
@@ -377,7 +366,7 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
} }
Ok(DailyQuotaDebitResult { Ok(DailyQuotaDebitResult {
debited_usd: debited, debited_usd: debited,
insufficient: false, insufficient,
}) })
} }
@@ -983,6 +972,108 @@ WHERE request_id = 'request-1'
assert_eq!(quota_used, 6.0); 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)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn sqlite_repository_serializes_concurrent_settlement_attempts() { async fn sqlite_repository_serializes_concurrent_settlement_attempts() {
let database_path = std::env::temp_dir().join(format!( let database_path = std::env::temp_dir().join(format!(
@@ -1098,10 +1189,23 @@ INSERT INTO wallets (
INSERT INTO "usage" ( INSERT INTO "usage" (
request_id, user_id, api_key_id, status, billing_status, request_id, user_id, api_key_id, status, billing_status,
total_cost_usd, actual_total_cost_usd total_cost_usd, actual_total_cost_usd
) VALUES ( ) VALUES
'request-quota-covered', 'user-quota', 'key-quota', 'completed', (
'pending', 3.0, 6.0 '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 ( INSERT INTO billing_plans (
id, title, price_amount, price_currency, duration_unit, id, title, price_amount, price_currency, duration_unit,
@@ -941,7 +941,11 @@
<SelectTrigger class="h-auto w-auto gap-1.5 !border-0 bg-transparent !shadow-none p-0 font-medium rounded-none flex-row-reverse !ring-0 !ring-offset-0 !outline-none [&>svg]:h-4 [&>svg]:w-4 [&>svg]:opacity-70"> <SelectTrigger class="h-auto w-auto gap-1.5 !border-0 bg-transparent !shadow-none p-0 font-medium rounded-none flex-row-reverse !ring-0 !ring-offset-0 !outline-none [&>svg]:h-4 [&>svg]:w-4 [&>svg]:opacity-70">
<SelectValue placeholder="选择格式..." /> <SelectValue placeholder="选择格式..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent
:disable-portal="false"
align="start"
class="max-h-[min(24rem,var(--radix-select-content-available-height))] min-w-[max(12rem,var(--radix-select-trigger-width))]"
>
<SelectItem <SelectItem
v-for="format in availableFormats" v-for="format in availableFormats"
:key="format.value" :key="format.value"
@@ -407,7 +407,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
close: [] close: []
saved: [] saved: [key: EndpointAPIKey]
}>() }>()
const { success, error: showError } = useToast() const { success, error: showError } = useToast()
@@ -1001,11 +1001,12 @@ async function handleSave() {
updateData.auth_config = authConfig updateData.auth_config = authConfig
} }
await updateProviderKey(props.editingKey.id, updateData) const updatedKey = await updateProviderKey(props.editingKey.id, updateData)
success(legacyT('密钥已更新'), legacyT('成功')) success(legacyT('密钥已更新'), legacyT('成功'))
emit('saved', updatedKey)
} else { } else {
// 新增模式 // 新增模式
await addProviderKey(props.providerId, { const createdKey = await addProviderKey(props.providerId, {
api_formats: form.value.api_formats, api_formats: form.value.api_formats,
api_key: form.value.api_key, api_key: form.value.api_key,
auth_type: form.value.auth_type, auth_type: form.value.auth_type,
@@ -1027,12 +1028,11 @@ async function handleSave() {
success(legacyT('密钥已添加'), legacyT('成功')) success(legacyT('密钥已添加'), legacyT('成功'))
// 添加模式:不关闭对话框,只清除名称和密钥以便继续添加 // 添加模式:不关闭对话框,只清除名称和密钥以便继续添加
emit('saved') emit('saved', createdKey)
clearForNextAdd() clearForNextAdd()
return return
} }
emit('saved')
emit('close') emit('close')
} catch (err: unknown) { } catch (err: unknown) {
const errorMessage = parseApiError(err, legacyT('保存密钥失败')) const errorMessage = parseApiError(err, legacyT('保存密钥失败'))
@@ -217,7 +217,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
close: [] close: []
saved: [] saved: [key: EndpointAPIKey]
}>() }>()
const { success, error: showError } = useToast() const { success, error: showError } = useToast()
@@ -390,9 +390,9 @@ async function handleSave() {
model_exclude_patterns: parsePatternText(form.value.model_exclude_patterns_text) 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('账号已更新', '成功') success('账号已更新', '成功')
emit('saved') emit('saved', updatedKey)
emit('close') emit('close')
} catch (err: unknown) { } catch (err: unknown) {
const errorMessage = parseApiError(err, '保存失败') const errorMessage = parseApiError(err, '保存失败')
@@ -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()]) await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
if (updatedKey) applyUpdatedKeySnapshot(updatedKey)
emit('refresh') emit('refresh')
// 添加/修改 key 后自动获取已支持 provider 的配额(新 key 的 upstream_metadata 为空) // 添加/修改 key 后自动获取已支持 provider 的配额(新 key 的 upstream_metadata 为空)
void autoRefreshQuotaInBackground().then((changed) => { void autoRefreshQuotaInBackground().then((changed) => {
@@ -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('<Select', modelIndex)
const formatSelector = source.slice(
selectStart,
source.indexOf('</Select>', 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)')
})
})
@@ -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 () => { 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, { const root = mountDialog(KeyFormDialog, {
open: true, open: true,
endpoint: null, endpoint: null,
@@ -379,6 +382,7 @@ describe('provider key concurrent_limit form behavior', () => {
providerId: 'provider-1', providerId: 'provider-1',
providerType: 'openai', providerType: 'openai',
availableApiFormats: ['openai:chat'], availableApiFormats: ['openai:chat'],
onSaved: saved,
}) })
await settle() await settle()
@@ -394,6 +398,7 @@ describe('provider key concurrent_limit form behavior', () => {
expect(typeof payload.concurrent_limit).toBe('number') expect(typeof payload.concurrent_limit).toBe('number')
expect(payload.concurrent_limit).not.toBe('') expect(payload.concurrent_limit).not.toBe('')
expect(payload.rpm_limit).toBe(42) 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 () => { it('serializes cleared normal key concurrent_limit as null instead of an empty string', async () => {