mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
feat(openai-quota): query + reset rate-limit credits for OpenAI accounts
Adds an admin-side action that mirrors the Codex Desktop "rate-limit reset" flow against chatgpt.com upstream for OpenAI OAuth accounts. Backend - OpenAIQuotaService.QueryUsage / ResetCredit hit /wham/usage and /wham/rate-limit-reset-credits/consume with the Codex Desktop header set, reusing OpenAITokenProvider for refreshed tokens and PrivacyClientFactory for the impersonated Chrome TLS fingerprint. - Honors the account's configured proxy by reading the eager-loaded account.Proxy directly (falls back to proxyRepo only when missing). - GET /api/v1/admin/openai/accounts/:id/quota POST /api/v1/admin/openai/accounts/:id/reset-quota - Wire DI for the new service + handler dependency. Frontend - OpenAIQuotaResetCell renders a single action row in AccountUsageCell's OpenAI section: the existing local "查询" (active sampling) is injected via #pre-actions, alongside a "次数 N" button that doubles as the upstream query trigger and the available-credit indicator, and a "重置" button that consumes one credit. - No duplicate 5h/7d window display; the local UsageProgressBar owns those bars to avoid confusion.
This commit is contained in:
@@ -705,6 +705,76 @@ export async function setPrivacy(id: number): Promise<Account> {
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI / Codex rate-limit reset feature: query and reset upstream usage.
|
||||
*/
|
||||
export interface OpenAIRateLimitWindow {
|
||||
used_percent: number
|
||||
limit_window_seconds: number
|
||||
reset_after_seconds: number
|
||||
reset_at: number
|
||||
}
|
||||
|
||||
export interface OpenAIRateLimit {
|
||||
allowed: boolean
|
||||
limit_reached: boolean
|
||||
primary_window?: OpenAIRateLimitWindow | null
|
||||
secondary_window?: OpenAIRateLimitWindow | null
|
||||
}
|
||||
|
||||
export interface OpenAIAdditionalRateLimit {
|
||||
limit_name: string
|
||||
metered_feature: string
|
||||
rate_limit?: OpenAIRateLimit | null
|
||||
}
|
||||
|
||||
export interface OpenAIRateLimitResetCredits {
|
||||
available_count: number
|
||||
}
|
||||
|
||||
export interface OpenAIQuotaUsage {
|
||||
user_id?: string
|
||||
account_id?: string
|
||||
email?: string
|
||||
plan_type?: string
|
||||
rate_limit?: OpenAIRateLimit | null
|
||||
additional_rate_limits?: OpenAIAdditionalRateLimit[]
|
||||
rate_limit_reset_credits?: OpenAIRateLimitResetCredits | null
|
||||
fetched_at: number
|
||||
}
|
||||
|
||||
export interface OpenAIQuotaResetCredit {
|
||||
id?: string
|
||||
reset_type?: string
|
||||
status?: string
|
||||
granted_at?: string
|
||||
expires_at?: string
|
||||
redeem_started_at?: string
|
||||
redeemed_at?: string
|
||||
}
|
||||
|
||||
export interface OpenAIQuotaResetResult {
|
||||
code: string
|
||||
credit?: OpenAIQuotaResetCredit | null
|
||||
windows_reset: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Query OpenAI/Codex rate-limit usage for an OAuth account.
|
||||
*/
|
||||
export async function queryOpenAIQuota(id: number): Promise<OpenAIQuotaUsage> {
|
||||
const { data } = await apiClient.get<OpenAIQuotaUsage>(`/admin/openai/accounts/${id}/quota`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume one rate-limit-reset credit for an OpenAI/Codex OAuth account.
|
||||
*/
|
||||
export async function resetOpenAIQuota(id: number): Promise<OpenAIQuotaResetResult> {
|
||||
const { data } = await apiClient.post<OpenAIQuotaResetResult>(`/admin/openai/accounts/${id}/reset-quota`)
|
||||
return data
|
||||
}
|
||||
|
||||
export const accountsAPI = {
|
||||
list,
|
||||
listWithEtag,
|
||||
@@ -746,7 +816,9 @@ export const accountsAPI = {
|
||||
batchClearError,
|
||||
batchRefresh,
|
||||
setPrivacy,
|
||||
revertProxyFallback
|
||||
revertProxyFallback,
|
||||
queryOpenAIQuota,
|
||||
resetOpenAIQuota
|
||||
}
|
||||
|
||||
export default accountsAPI
|
||||
|
||||
@@ -126,30 +126,37 @@
|
||||
:show-now-when-idle="true"
|
||||
color="emerald"
|
||||
/>
|
||||
<div class="flex items-center gap-1.5 mt-0.5">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[9px] font-medium text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/30 transition-colors"
|
||||
:disabled="activeQueryLoading"
|
||||
@click="loadActiveUsage"
|
||||
>
|
||||
<svg
|
||||
class="h-2.5 w-2.5"
|
||||
:class="{ 'animate-spin': activeQueryLoading }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
<!--
|
||||
Upstream codex /wham/usage quota query + reset. The local active-sampling
|
||||
refresh button is rendered via the pre-actions slot so the user sees a
|
||||
single row of related buttons instead of two stacked rows.
|
||||
-->
|
||||
<OpenAIQuotaResetCell :account="account">
|
||||
<template #pre-actions>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/30 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="activeQueryLoading"
|
||||
@click="loadActiveUsage"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.usageWindow.activeQuery') }}
|
||||
</button>
|
||||
</div>
|
||||
<svg
|
||||
class="h-2.5 w-2.5"
|
||||
:class="{ 'animate-spin': activeQueryLoading }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.usageWindow.activeQuery') }}
|
||||
</button>
|
||||
</template>
|
||||
</OpenAIQuotaResetCell>
|
||||
</div>
|
||||
<div v-else-if="loading" class="space-y-1.5">
|
||||
<div class="flex items-center gap-1">
|
||||
@@ -163,7 +170,11 @@
|
||||
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400">-</div>
|
||||
<div v-else>
|
||||
<div class="text-xs text-gray-400">-</div>
|
||||
<!-- Always allow on-demand upstream quota query, even before local data exists. -->
|
||||
<OpenAIQuotaResetCell :account="account" class="mt-1" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Antigravity OAuth accounts: fetch usage from API -->
|
||||
@@ -503,6 +514,7 @@ import { enqueueUsageRequest } from '@/utils/usageLoadQueue'
|
||||
import { formatCompactNumber } from '@/utils/format'
|
||||
import UsageProgressBar from './UsageProgressBar.vue'
|
||||
import AccountQuotaInfo from './AccountQuotaInfo.vue'
|
||||
import OpenAIQuotaResetCell from './OpenAIQuotaResetCell.vue'
|
||||
|
||||
// Module-level cache shared across all AccountUsageCell instances
|
||||
const _usageCache = new Map<number, { data: AccountUsageInfo; ts: number }>()
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div v-if="visible" class="space-y-1">
|
||||
<!--
|
||||
Unified action row. Parents that already render their own "local query"
|
||||
affordance (e.g. AccountUsageCell's active-sampling refresh) pass it in
|
||||
via the #pre-actions slot so the user sees a single row of related
|
||||
buttons rather than two near-duplicate "查询" rows.
|
||||
|
||||
The 5h / 7d window bars are deliberately NOT rendered here — the local
|
||||
active-sampling display (UsageProgressBar in AccountUsageCell) already
|
||||
owns that real estate. This cell is purely about the rate-limit reset
|
||||
credit: query its count, consume one if needed.
|
||||
-->
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<slot name="pre-actions" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium text-blue-600 transition-colors hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-blue-400 dark:hover:bg-blue-900/30"
|
||||
:disabled="loading || resetting"
|
||||
:title="countButtonTitle"
|
||||
@click="handleQuery"
|
||||
>
|
||||
<svg
|
||||
class="h-2.5 w-2.5"
|
||||
:class="{ 'animate-spin': loading }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.openaiQuotaReset.count') }}<span v-if="data"> {{ availableResetCount }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium text-orange-600 transition-colors hover:bg-orange-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-orange-400 dark:hover:bg-orange-900/30"
|
||||
:disabled="resetting || loading || !canReset"
|
||||
:title="resetButtonTitle"
|
||||
@click="handleReset"
|
||||
>
|
||||
<svg
|
||||
class="h-2.5 w-2.5"
|
||||
:class="{ 'animate-spin': resetting }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 12a8 8 0 11-2.343-5.657L20 8m0 0V4m0 4h-4"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.openaiQuotaReset.reset') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error / success feedback -->
|
||||
<div
|
||||
v-if="error"
|
||||
class="text-[10px] text-red-600 dark:text-red-400"
|
||||
:title="error"
|
||||
>
|
||||
{{ truncatedError }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="resetMessage"
|
||||
class="text-[10px] text-emerald-600 dark:text-emerald-400"
|
||||
>
|
||||
{{ resetMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Account } from '@/types'
|
||||
import {
|
||||
queryOpenAIQuota,
|
||||
resetOpenAIQuota,
|
||||
type OpenAIQuotaUsage,
|
||||
type OpenAIQuotaResetResult
|
||||
} from '@/api/admin/accounts'
|
||||
|
||||
const props = defineProps<{
|
||||
account: Account
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Visible only for OpenAI OAuth accounts.
|
||||
const visible = computed(() => props.account.platform === 'openai' && props.account.type === 'oauth')
|
||||
|
||||
const loading = ref(false)
|
||||
const resetting = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const data = ref<OpenAIQuotaUsage | null>(null)
|
||||
const resetMessage = ref<string | null>(null)
|
||||
|
||||
const availableResetCount = computed(() => data.value?.rate_limit_reset_credits?.available_count ?? 0)
|
||||
const canReset = computed(() => availableResetCount.value > 0)
|
||||
|
||||
const resetButtonTitle = computed(() => {
|
||||
if (!data.value) return t('admin.accounts.openaiQuotaReset.resetTooltipNeedQuery')
|
||||
if (!canReset.value) return t('admin.accounts.openaiQuotaReset.resetTooltipNoCredits')
|
||||
return t('admin.accounts.openaiQuotaReset.resetTooltipReady')
|
||||
})
|
||||
|
||||
// "次数" button doubles as the upstream-query trigger and the count display.
|
||||
// Tooltip differs between "click to load" (no data yet) and "click to refresh".
|
||||
const countButtonTitle = computed(() => {
|
||||
if (!data.value) return t('admin.accounts.openaiQuotaReset.countTooltipLoad')
|
||||
return t('admin.accounts.openaiQuotaReset.countTooltipRefresh')
|
||||
})
|
||||
|
||||
const truncatedError = computed(() => {
|
||||
if (!error.value) return ''
|
||||
return error.value.length > 80 ? `${error.value.slice(0, 80)}…` : error.value
|
||||
})
|
||||
|
||||
const extractErrorMessage = (e: unknown): string => {
|
||||
// The project's axios response interceptor (api/client.ts) flattens server
|
||||
// errors into { status, code, message, reason, ... } and re-rejects them, so
|
||||
// the message lives at the top level rather than under .response.data. Fall
|
||||
// back to the raw axios shape for the cancellation/network branches that
|
||||
// bypass the flattening, and finally to the generic i18n string.
|
||||
const err = e as {
|
||||
message?: string
|
||||
reason?: string
|
||||
response?: { data?: { message?: string; error?: string } }
|
||||
}
|
||||
return (
|
||||
err?.message ||
|
||||
err?.reason ||
|
||||
err?.response?.data?.message ||
|
||||
err?.response?.data?.error ||
|
||||
t('common.error')
|
||||
)
|
||||
}
|
||||
|
||||
const handleQuery = async () => {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
resetMessage.value = null
|
||||
try {
|
||||
data.value = await queryOpenAIQuota(props.account.id)
|
||||
} catch (e) {
|
||||
error.value = extractErrorMessage(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = async () => {
|
||||
if (resetting.value) return
|
||||
if (!canReset.value) {
|
||||
error.value = t('admin.accounts.openaiQuotaReset.noCreditsAvailable')
|
||||
return
|
||||
}
|
||||
resetting.value = true
|
||||
error.value = null
|
||||
resetMessage.value = null
|
||||
try {
|
||||
const result: OpenAIQuotaResetResult = await resetOpenAIQuota(props.account.id)
|
||||
// Refresh the reset-credit count so the badge reflects the consumed credit.
|
||||
// handleQuery clears resetMessage on entry, so the success toast is set
|
||||
// AFTER it resolves.
|
||||
await handleQuery()
|
||||
resetMessage.value = t('admin.accounts.openaiQuotaReset.resetSuccess', {
|
||||
windows: result.windows_reset
|
||||
})
|
||||
} catch (e) {
|
||||
error.value = extractErrorMessage(e)
|
||||
} finally {
|
||||
resetting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.account.id,
|
||||
() => {
|
||||
// Account row may be reused across paginated lists; reset local state.
|
||||
data.value = null
|
||||
error.value = null
|
||||
resetMessage.value = null
|
||||
loading.value = false
|
||||
resetting.value = false
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -4105,6 +4105,17 @@ export default {
|
||||
passiveSampled: 'Passive',
|
||||
activeQuery: 'Query'
|
||||
},
|
||||
openaiQuotaReset: {
|
||||
count: 'Credits',
|
||||
reset: 'Reset',
|
||||
countTooltipLoad: 'Click to load the available reset-credit count',
|
||||
countTooltipRefresh: 'Click to refresh the available reset-credit count',
|
||||
resetTooltipReady: 'Consume 1 reset credit to immediately restore the window',
|
||||
resetTooltipNeedQuery: 'Click Credits first to load the available count',
|
||||
resetTooltipNoCredits: 'No reset credits available',
|
||||
noCreditsAvailable: 'No reset credits available',
|
||||
resetSuccess: 'Reset {windows} window(s)'
|
||||
},
|
||||
tier: {
|
||||
free: 'Free',
|
||||
pro: 'Pro',
|
||||
|
||||
@@ -3393,6 +3393,17 @@ export default {
|
||||
passiveSampled: '被动采样',
|
||||
activeQuery: '查询'
|
||||
},
|
||||
openaiQuotaReset: {
|
||||
count: '次数',
|
||||
reset: '重置',
|
||||
countTooltipLoad: '点击查询剩余重置次数',
|
||||
countTooltipRefresh: '点击刷新剩余重置次数',
|
||||
resetTooltipReady: '消耗 1 次重置次数以立即恢复当前窗口',
|
||||
resetTooltipNeedQuery: '先点击「次数」加载剩余重置次数',
|
||||
resetTooltipNoCredits: '没有可用的重置次数',
|
||||
noCreditsAvailable: '没有可用的重置次数',
|
||||
resetSuccess: '已重置 {windows} 个窗口'
|
||||
},
|
||||
tier: {
|
||||
free: 'Free',
|
||||
pro: 'Pro',
|
||||
|
||||
Reference in New Issue
Block a user