feat: add quota limit for API key accounts

- Add configurable spending limit (quota_limit) for apikey-type accounts
- Atomic quota accumulation via PostgreSQL JSONB operations on TotalCost
- Scheduler filters out over-quota accounts with outbox-triggered snapshot refresh
- Display quota usage ($used / $limit) in account capacity column
- Add "Reset Quota" action in account menu to reset usage to zero
- Editing account settings preserves quota_used (no accidental reset)
- Covers all 3 billing paths: Anthropic, Gemini, OpenAI RecordUsage

chore: bump version to 0.1.90.4
This commit is contained in:
erio
2026-03-05 20:54:37 +08:00
parent ae5d9c8bfc
commit 05527b13db
24 changed files with 398 additions and 6 deletions

View File

@@ -759,6 +759,26 @@
</div>
</div>
<!-- API Key 账号配额限制 -->
<div
v-if="account?.type === 'apikey'"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<label class="input-label">{{ t('admin.accounts.quotaLimit') }}</label>
<div class="flex items-center gap-2">
<span class="text-gray-500">$</span>
<input
v-model.number="editQuotaLimit"
type="number"
min="0"
step="0.01"
class="input"
:placeholder="t('admin.accounts.quotaLimitPlaceholder')"
/>
</div>
<p class="input-hint">{{ t('admin.accounts.quotaLimitHint') }}</p>
</div>
<!-- OpenAI OAuth Codex 官方客户端限制开关 -->
<div
v-if="account?.platform === 'openai' && account?.type === 'oauth'"
@@ -1386,6 +1406,7 @@ const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
const codexCLIOnlyEnabled = ref(false)
const anthropicPassthroughEnabled = ref(false)
const editQuotaLimit = ref<number | null>(null)
const openAIWSModeOptions = computed(() => [
{ value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') },
// TODO: ctx_pool 选项暂时隐藏,待测试完成后恢复
@@ -1541,6 +1562,14 @@ watch(
anthropicPassthroughEnabled.value = extra?.anthropic_passthrough === true
}
// Load quota limit for apikey accounts
if (newAccount.type === 'apikey') {
const quotaVal = extra?.quota_limit as number | undefined
editQuotaLimit.value = (quotaVal && quotaVal > 0) ? quotaVal : null
} else {
editQuotaLimit.value = null
}
// Load antigravity model mapping (Antigravity 只支持映射模式)
if (newAccount.platform === 'antigravity') {
const credentials = newAccount.credentials as Record<string, unknown> | undefined
@@ -2283,6 +2312,19 @@ const handleSubmit = async () => {
updatePayload.extra = newExtra
}
// For apikey accounts, handle quota_limit in extra
if (props.account.type === 'apikey') {
const currentExtra = (updatePayload.extra as Record<string, unknown>) ||
(props.account.extra as Record<string, unknown>) || {}
const newExtra: Record<string, unknown> = { ...currentExtra }
if (editQuotaLimit.value != null && editQuotaLimit.value > 0) {
newExtra.quota_limit = editQuotaLimit.value
} else {
delete newExtra.quota_limit
}
updatePayload.extra = newExtra
}
const canContinue = await ensureAntigravityMixedChannelConfirmed(async () => {
await submitUpdateAccount(accountID, updatePayload)
})