mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
feat: add grok quota probe parity
This commit is contained in:
@@ -39,6 +39,39 @@ export interface GrokTokenInfo {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface GrokQuotaWindow {
|
||||
limit?: number | null
|
||||
remaining?: number | null
|
||||
reset_unix?: number | null
|
||||
reset_at?: string | null
|
||||
}
|
||||
|
||||
export interface GrokQuotaSnapshot {
|
||||
requests?: GrokQuotaWindow | null
|
||||
tokens?: GrokQuotaWindow | null
|
||||
retry_after_seconds?: number | null
|
||||
subscription_tier?: string
|
||||
entitlement_status?: string
|
||||
status_code?: number
|
||||
headers?: Record<string, string>
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface GrokQuotaProbeResult {
|
||||
source: 'active_probe'
|
||||
snapshot?: GrokQuotaSnapshot | null
|
||||
status_code?: number
|
||||
headers_observed: boolean
|
||||
reset_supported: boolean
|
||||
fetched_at: number
|
||||
}
|
||||
|
||||
export interface GrokQuotaResetResult {
|
||||
supported: boolean
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export async function generateAuthUrl(
|
||||
payload: GrokAuthUrlRequest
|
||||
): Promise<GrokAuthUrlResponse> {
|
||||
@@ -71,4 +104,14 @@ export async function refreshGrokToken(
|
||||
return data
|
||||
}
|
||||
|
||||
export default { generateAuthUrl, exchangeCode, refreshGrokToken }
|
||||
export async function queryQuota(id: number): Promise<GrokQuotaProbeResult> {
|
||||
const { data } = await apiClient.get<GrokQuotaProbeResult>(`/admin/grok/accounts/${id}/quota`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function resetQuota(id: number): Promise<GrokQuotaResetResult> {
|
||||
const { data } = await apiClient.post<GrokQuotaResetResult>(`/admin/grok/accounts/${id}/reset-quota`)
|
||||
return data
|
||||
}
|
||||
|
||||
export default { generateAuthUrl, exchangeCode, refreshGrokToken, queryQuota, resetQuota }
|
||||
|
||||
@@ -384,6 +384,7 @@
|
||||
<div v-else-if="usageInfo.error" class="truncate text-xs text-amber-600 dark:text-amber-400 max-w-[200px]" :title="usageInfo.error">
|
||||
{{ usageErrorLabel }}
|
||||
</div>
|
||||
<GrokQuotaProbeCell :account="account" />
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400">-</div>
|
||||
</template>
|
||||
@@ -583,6 +584,7 @@ import { formatCompactNumber } from '@/utils/format'
|
||||
import UsageProgressBar from './UsageProgressBar.vue'
|
||||
import AccountQuotaInfo from './AccountQuotaInfo.vue'
|
||||
import OpenAIQuotaResetCell from './OpenAIQuotaResetCell.vue'
|
||||
import GrokQuotaProbeCell from './GrokQuotaProbeCell.vue'
|
||||
|
||||
// Module-level cache shared across all AccountUsageCell instances
|
||||
const _usageCache = new Map<number, { data: AccountUsageInfo; ts: number }>()
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div v-if="visible" class="space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium text-cyan-700 transition-colors hover:bg-cyan-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-cyan-300 dark:hover:bg-cyan-900/30"
|
||||
:disabled="loading"
|
||||
:title="t('admin.accounts.usageWindow.grokProbeTooltip')"
|
||||
@click="handleProbe"
|
||||
>
|
||||
<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.usageWindow.grokProbe') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex cursor-not-allowed items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium text-gray-400 opacity-70 dark:text-gray-500"
|
||||
disabled
|
||||
:title="t('admin.accounts.usageWindow.grokResetUnsupportedTooltip')"
|
||||
>
|
||||
{{ t('admin.accounts.usageWindow.grokResetUnsupported') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="summary" class="text-[10px] text-gray-600 dark:text-gray-300">
|
||||
{{ summary }}
|
||||
</div>
|
||||
<div v-if="error" class="truncate text-[10px] text-red-600 dark:text-red-400" :title="error">
|
||||
{{ truncatedError }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { GrokQuotaProbeResult, GrokQuotaWindow } from '@/api/admin/grok'
|
||||
import type { Account } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
account: Account
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const visible = computed(() => props.account.platform === 'grok' && props.account.type === 'oauth')
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const data = ref<GrokQuotaProbeResult | null>(null)
|
||||
|
||||
const extractErrorMessage = (e: unknown): 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 formatWindow = (label: string, window?: GrokQuotaWindow | null): string | null => {
|
||||
if (!window || window.limit == null || window.remaining == null) return null
|
||||
return `${label} ${window.remaining}/${window.limit}`
|
||||
}
|
||||
|
||||
const retryAfterLabel = computed(() => {
|
||||
const seconds = data.value?.snapshot?.retry_after_seconds
|
||||
if (seconds == null || seconds <= 0) return null
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
return `${Math.ceil(seconds / 60)}m`
|
||||
})
|
||||
|
||||
const summary = computed(() => {
|
||||
const snapshot = data.value?.snapshot
|
||||
if (!data.value) return ''
|
||||
if (!snapshot) return t('admin.accounts.usageWindow.grokNoHeaders')
|
||||
const parts = [
|
||||
formatWindow(t('admin.accounts.usageWindow.grokRequests'), snapshot.requests),
|
||||
formatWindow(t('admin.accounts.usageWindow.grokTokens'), snapshot.tokens)
|
||||
].filter(Boolean)
|
||||
if (retryAfterLabel.value) {
|
||||
parts.push(t('admin.accounts.usageWindow.grokRetryAfter', { time: retryAfterLabel.value }))
|
||||
}
|
||||
if (snapshot.entitlement_status) {
|
||||
parts.push(snapshot.entitlement_status)
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' | ') : t('admin.accounts.usageWindow.grokNoHeaders')
|
||||
})
|
||||
|
||||
const truncatedError = computed(() => {
|
||||
if (!error.value) return ''
|
||||
return error.value.length > 80 ? `${error.value.slice(0, 80)}...` : error.value
|
||||
})
|
||||
|
||||
const handleProbe = async () => {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
data.value = await adminAPI.grok.queryQuota(props.account.id)
|
||||
} catch (e) {
|
||||
error.value = extractErrorMessage(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.account.id,
|
||||
() => {
|
||||
data.value = null
|
||||
error.value = null
|
||||
loading.value = false
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -4154,6 +4154,11 @@ export default {
|
||||
grokTokens: 'Tok',
|
||||
grokUnknown: 'Grok quota is unknown until the first upstream response includes xAI rate-limit headers.',
|
||||
grokRetryAfter: 'Retry after {time}',
|
||||
grokProbe: 'Probe',
|
||||
grokProbeTooltip: 'Send a minimal xAI Responses probe and read quota headers',
|
||||
grokResetUnsupported: 'Reset unsupported',
|
||||
grokResetUnsupportedTooltip: 'xAI does not expose reset credits for Grok OAuth accounts',
|
||||
grokNoHeaders: 'No quota headers observed',
|
||||
passiveSampled: 'Passive',
|
||||
activeQuery: 'Query'
|
||||
},
|
||||
|
||||
@@ -3412,6 +3412,11 @@ export default {
|
||||
grokTokens: 'Token',
|
||||
grokUnknown: 'Grok 配额需等待首次上游响应返回 xAI rate-limit 头后显示。',
|
||||
grokRetryAfter: '{time} 后重试',
|
||||
grokProbe: '探测',
|
||||
grokProbeTooltip: '发送最小 xAI Responses 探测请求并读取配额响应头',
|
||||
grokResetUnsupported: '不支持重置',
|
||||
grokResetUnsupportedTooltip: 'xAI 未向 Grok OAuth 账号开放重置额度接口',
|
||||
grokNoHeaders: '未观察到配额响应头',
|
||||
passiveSampled: '被动采样',
|
||||
activeQuery: '查询'
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user