Merge pull request #3310 from heathermhuang/codex/grok-subscription-support

feat: add grok subscription support
This commit is contained in:
Wesley Liddick
2026-06-26 15:41:52 +08:00
committed by GitHub
109 changed files with 5594 additions and 224 deletions
@@ -102,7 +102,11 @@
</div>
<!-- No data yet -->
<div v-else class="text-xs text-gray-400">-</div>
<div v-else class="space-y-1">
<div class="text-xs text-gray-400">-</div>
<!-- Always allow on-demand upstream quota probe, even before passive headers exist. -->
<GrokQuotaProbeCell :account="account" />
</div>
</template>
<!-- OpenAI OAuth accounts: single source from /usage API -->
@@ -320,6 +324,78 @@
<div v-else class="text-xs text-gray-400">-</div>
</template>
<!-- Grok OAuth accounts: passive xAI quota headers + local Sub2API usage -->
<template v-else-if="account.platform === 'grok' && account.type === 'oauth'">
<div v-if="loading" class="space-y-1.5">
<div class="flex items-center gap-1">
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
</div>
<div v-else-if="error" class="text-xs text-red-500">
{{ error }}
</div>
<div v-else-if="needsReauth" class="space-y-1">
<span class="inline-block rounded px-1.5 py-0.5 text-[10px] font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300">
{{ t('admin.accounts.needsReauth') }}
</span>
</div>
<div v-else-if="isForbidden" class="space-y-1">
<span class="inline-block rounded px-1.5 py-0.5 text-[10px] font-medium bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300">
{{ grokEntitlementLabel || t('admin.accounts.forbidden') }}
</span>
</div>
<div v-else-if="usageInfo" class="space-y-1">
<div v-if="grokEntitlementLabel" class="mb-0.5">
<span class="inline-block rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-700 dark:bg-slate-800 dark:text-slate-300">
{{ grokEntitlementLabel }}
</span>
</div>
<div v-if="grokLocalUsage" class="mb-0.5 flex items-center">
<div class="flex items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400">
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
{{ formatWindowRequests(grokLocalUsage) }} req
</span>
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
{{ formatWindowTokens(grokLocalUsage) }}
</span>
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800" :title="t('usage.accountBilled')">
A ${{ formatWindowCost(grokLocalUsage) }}
</span>
</div>
</div>
<UsageProgressBar
v-if="grokRequestQuotaBar"
:label="t('admin.accounts.usageWindow.grokRequests')"
:utilization="grokRequestQuotaBar.utilization"
:resets-at="grokRequestQuotaBar.resetsAt"
color="indigo"
/>
<UsageProgressBar
v-if="grokTokenQuotaBar"
:label="t('admin.accounts.usageWindow.grokTokens')"
:utilization="grokTokenQuotaBar.utilization"
:resets-at="grokTokenQuotaBar.resetsAt"
color="emerald"
/>
<div v-if="grokRetryAfterLabel" class="text-[10px] text-amber-600 dark:text-amber-400">
{{ t('admin.accounts.usageWindow.grokRetryAfter', { time: grokRetryAfterLabel }) }}
</div>
<div v-if="grokQuotaUnknown" class="text-[10px] text-gray-500 dark:text-gray-400">
{{ grokQuotaUnknownLabel }}
</div>
<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>
<div v-if="grokQuotaStatusLine" class="text-[10px] text-gray-500 dark:text-gray-400">
{{ grokQuotaStatusLine }}
</div>
<GrokQuotaProbeCell :account="account" />
</div>
<div v-else class="text-xs text-gray-400">-</div>
</template>
<!-- Gemini platform: show quota + local usage window -->
<template v-else-if="account.platform === 'gemini'">
<!-- Auth Type + Tier Badge (first line) -->
@@ -511,10 +587,11 @@ import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types'
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
import { enqueueUsageRequest } from '@/utils/usageLoadQueue'
import { formatCompactNumber } from '@/utils/format'
import { formatCompactNumber, formatRelativeTime } 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 }>()
@@ -573,6 +650,9 @@ const shouldFetchUsage = computed(() => {
if (props.account.platform === 'antigravity') {
return props.account.type === 'oauth'
}
if (props.account.platform === 'grok') {
return props.account.type === 'oauth'
}
if (props.account.platform === 'openai') {
return props.account.type === 'oauth'
}
@@ -933,6 +1013,72 @@ const geminiUsageBars = computed(() => {
return bars
})
interface GrokQuotaBarInfo {
utilization: number
resetsAt: string | null
}
const makeGrokQuotaBar = (quota?: { limit?: number | null; remaining?: number | null; reset_at?: string | null } | null): GrokQuotaBarInfo | null => {
if (!quota || quota.limit == null || quota.remaining == null || quota.limit <= 0) return null
const used = Math.max(0, quota.limit - quota.remaining)
return {
utilization: Math.min(100, (used / quota.limit) * 100),
resetsAt: quota.reset_at || null
}
}
const grokRequestQuotaBar = computed(() => makeGrokQuotaBar(usageInfo.value?.grok_request_quota))
const grokTokenQuotaBar = computed(() => makeGrokQuotaBar(usageInfo.value?.grok_token_quota))
const grokQuotaUnknown = computed(() => {
if (props.account.platform !== 'grok') return false
if (grokRequestQuotaBar.value || grokTokenQuotaBar.value) return false
return usageInfo.value?.grok_quota_snapshot_state !== 'observed'
})
const grokQuotaUnknownLabel = computed(() => {
return usageInfo.value?.grok_quota_snapshot_state === 'no_headers'
? t('admin.accounts.usageWindow.grokNoHeaders')
: t('admin.accounts.usageWindow.grokUnknown')
})
const grokQuotaStatusLine = computed(() => {
if (props.account.platform !== 'grok') return null
const parts: string[] = []
const status = usageInfo.value?.grok_last_status_code
if (status) {
parts.push(t('admin.accounts.usageWindow.grokLastStatus', { status }))
}
if (usageInfo.value?.grok_last_quota_probe_at) {
parts.push(
t('admin.accounts.usageWindow.grokLastProbe', {
time: formatRelativeTime(usageInfo.value.grok_last_quota_probe_at)
})
)
}
if (usageInfo.value?.grok_last_headers_seen_at) {
parts.push(
t('admin.accounts.usageWindow.grokLastHeadersSeen', {
time: formatRelativeTime(usageInfo.value.grok_last_headers_seen_at)
})
)
}
return parts.length > 0 ? parts.join(' | ') : null
})
const grokLocalUsage = computed(() => usageInfo.value?.grok_local_usage || props.todayStats || null)
const grokEntitlementLabel = computed(() => {
const status = (usageInfo.value?.grok_entitlement_status || '').trim()
return status || null
})
const grokRetryAfterLabel = computed(() => {
const seconds = usageInfo.value?.grok_retry_after_seconds
if (seconds == null || seconds <= 0) return null
if (seconds < 60) return `${seconds}s`
const minutes = Math.ceil(seconds / 60)
return `${minutes}m`
})
const formatWindowRequests = (stats: WindowStats) => formatCompactNumber(stats.requests, { allowBillions: false })
const formatWindowTokens = (stats: WindowStats) => formatCompactNumber(stats.tokens)
const formatWindowCost = (stats: WindowStats) => stats.cost.toFixed(2)
// 账户类型显示标签
const antigravityTierLabel = computed(() => {
switch (antigravityTier.value) {
@@ -1039,7 +1185,9 @@ const loadUsage = async (options?: { source?: 'passive' | 'active'; bypassCache?
error.value = null
try {
const fetchFn = () => adminAPI.accounts.getUsage(props.account.id, options?.source)
const fetchFn = () => options?.source
? adminAPI.accounts.getUsage(props.account.id, options.source)
: adminAPI.accounts.getUsage(props.account.id)
const result = await enqueueUsageRequest(props.account, fetchFn)
if (!unmounted.value) {
usageInfo.value = result
@@ -1226,6 +1374,7 @@ watch(openAIUsageRefreshKey, (nextKey, prevKey) => {
if (!prevKey || nextKey === prevKey) return
if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return
_usageCache.delete(props.account.id)
requestAutoLoad()
})
@@ -70,7 +70,7 @@
<!-- Platform Selection - Segmented Control Style -->
<div>
<label class="input-label">{{ t('admin.accounts.platform') }}</label>
<div class="mt-2 flex rounded-lg bg-gray-100 p-1 dark:bg-dark-700" data-tour="account-form-platform">
<div class="mt-2 flex flex-wrap rounded-lg bg-gray-100 p-1 dark:bg-dark-700" data-tour="account-form-platform">
<button
type="button"
@click="form.platform = 'anthropic'"
@@ -147,6 +147,19 @@
<Icon name="cloud" size="sm" />
Antigravity
</button>
<button
type="button"
@click="form.platform = 'grok'"
:class="[
'flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
form.platform === 'grok'
? 'bg-white text-slate-700 shadow-sm dark:bg-dark-600 dark:text-slate-200'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
]"
>
<PlatformIcon platform="grok" size="sm" />
Grok
</button>
</div>
</div>
@@ -339,6 +352,41 @@
</div>
</div>
<!-- Account Type Selection (Grok - OAuth only) -->
<div v-if="form.platform === 'grok'">
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
<div class="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2" data-tour="account-form-type">
<button
type="button"
@click="accountCategory = 'oauth-based'"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountCategory === 'oauth-based'
? 'border-slate-500 bg-slate-50 dark:bg-slate-900/20'
: 'border-gray-200 hover:border-slate-300 dark:border-dark-600 dark:hover:border-slate-700'
]"
>
<div
:class="[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
accountCategory === 'oauth-based'
? 'bg-slate-700 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<PlatformIcon platform="grok" size="sm" />
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.grokOauth') }}</span>
</div>
</button>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.oauth.grok.oauthOnlyHint') }}
</p>
</div>
<!-- Account Type Selection (Gemini) -->
<div v-if="form.platform === 'gemini'">
<div class="flex items-center justify-between">
@@ -1780,7 +1828,7 @@
<!-- OpenAI OAuth Model Mapping (OAuth 类型没有 apikey 容器,需要独立的模型映射区域) -->
<div
v-if="form.platform === 'openai' && accountCategory === 'oauth-based'"
v-if="(form.platform === 'openai' || form.platform === 'grok') && accountCategory === 'oauth-based'"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
@@ -2873,10 +2921,10 @@
:loading="currentOAuthLoading"
:error="currentOAuthError"
:show-help="form.platform === 'anthropic'"
:show-proxy-warning="form.platform !== 'openai' && !!form.proxy_id"
:show-proxy-warning="form.platform !== 'openai' && form.platform !== 'grok' && !!form.proxy_id"
:allow-multiple="form.platform === 'anthropic'"
:show-cookie-option="form.platform === 'anthropic'"
:show-refresh-token-option="form.platform === 'openai' || form.platform === 'antigravity'"
:show-refresh-token-option="form.platform === 'openai' || form.platform === 'antigravity' || form.platform === 'grok'"
:show-mobile-refresh-token-option="form.platform === 'openai'"
:show-session-token-option="false"
:show-access-token-option="false"
@@ -3231,6 +3279,7 @@ import {
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
import { useGrokOAuth } from '@/composables/useGrokOAuth'
import type {
Proxy,
AdminGroup,
@@ -3246,6 +3295,7 @@ import type {
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Select from '@/components/common/Select.vue'
import PlatformIcon from '@/components/common/PlatformIcon.vue'
import Icon from '@/components/icons/Icon.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
import ProxyAdBanner from '@/components/common/ProxyAdBanner.vue'
@@ -3291,6 +3341,7 @@ const oauthStepTitle = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.oauth.openai.title')
if (form.platform === 'gemini') return t('admin.accounts.oauth.gemini.title')
if (form.platform === 'antigravity') return t('admin.accounts.oauth.antigravity.title')
if (form.platform === 'grok') return t('admin.accounts.oauth.grok.title')
return t('admin.accounts.oauth.title')
})
@@ -3298,12 +3349,14 @@ const oauthStepTitle = computed(() => {
const baseUrlHint = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.openai.baseUrlHint')
if (form.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint')
if (form.platform === 'grok') return t('admin.accounts.grok.baseUrlHint')
return t('admin.accounts.baseUrlHint')
})
const apiKeyHint = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.openai.apiKeyHint')
if (form.platform === 'gemini') return t('admin.accounts.gemini.apiKeyHint')
if (form.platform === 'grok') return t('admin.accounts.grok.apiKeyHint')
return t('admin.accounts.apiKeyHint')
})
@@ -3326,12 +3379,14 @@ const oauth = useAccountOAuth() // For Anthropic OAuth
const openaiOAuth = useOpenAIOAuth() // For OpenAI OAuth
const geminiOAuth = useGeminiOAuth() // For Gemini OAuth
const antigravityOAuth = useAntigravityOAuth() // For Antigravity OAuth
const grokOAuth = useGrokOAuth() // For Grok OAuth
// Computed: current OAuth state for template binding
const currentAuthUrl = computed(() => {
if (form.platform === 'openai') return openaiOAuth.authUrl.value
if (form.platform === 'gemini') return geminiOAuth.authUrl.value
if (form.platform === 'antigravity') return antigravityOAuth.authUrl.value
if (form.platform === 'grok') return grokOAuth.authUrl.value
return oauth.authUrl.value
})
@@ -3339,6 +3394,7 @@ const currentSessionId = computed(() => {
if (form.platform === 'openai') return openaiOAuth.sessionId.value
if (form.platform === 'gemini') return geminiOAuth.sessionId.value
if (form.platform === 'antigravity') return antigravityOAuth.sessionId.value
if (form.platform === 'grok') return grokOAuth.sessionId.value
return oauth.sessionId.value
})
@@ -3346,6 +3402,7 @@ const currentOAuthLoading = computed(() => {
if (form.platform === 'openai') return openaiOAuth.loading.value
if (form.platform === 'gemini') return geminiOAuth.loading.value
if (form.platform === 'antigravity') return antigravityOAuth.loading.value
if (form.platform === 'grok') return grokOAuth.loading.value
return oauth.loading.value
})
@@ -3353,6 +3410,7 @@ const currentOAuthError = computed(() => {
if (form.platform === 'openai') return openaiOAuth.error.value
if (form.platform === 'gemini') return geminiOAuth.error.value
if (form.platform === 'antigravity') return antigravityOAuth.error.value
if (form.platform === 'grok') return grokOAuth.error.value
return oauth.error.value
})
@@ -3750,6 +3808,9 @@ const canExchangeCode = computed(() => {
if (form.platform === 'antigravity') {
return authCode.trim() && antigravityOAuth.sessionId.value && !antigravityOAuth.loading.value
}
if (form.platform === 'grok') {
return authCode.trim() && grokOAuth.sessionId.value && !grokOAuth.loading.value
}
return authCode.trim() && oauth.sessionId.value && !oauth.loading.value
})
@@ -3799,7 +3860,7 @@ watch(
if ((form.platform === 'gemini' || form.platform === 'anthropic') && category === 'service_account') {
form.type = 'service_account' as AccountType
} else if (category === 'oauth-based') {
form.type = method as AccountType // 'oauth' or 'setup-token'
form.type = form.platform === 'anthropic' ? method as AccountType : 'oauth'
} else {
form.type = 'apikey'
}
@@ -3817,7 +3878,9 @@ watch(
? 'https://api.openai.com'
: newPlatform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
: newPlatform === 'grok'
? 'https://api.x.ai/v1'
: 'https://api.anthropic.com'
// Clear model-related settings
allowedModels.value = []
modelMappings.value = []
@@ -3837,6 +3900,13 @@ watch(
antigravityModelMappings.value = []
antigravityModelRestrictionMode.value = 'mapping'
}
if (newPlatform === 'grok') {
accountCategory.value = 'oauth-based'
addMethod.value = 'oauth'
modelRestrictionMode.value = 'mapping'
form.concurrency = 1
form.load_factor = null
}
if (newPlatform !== 'gemini' && newPlatform !== 'anthropic' && accountCategory.value === 'service_account') {
accountCategory.value = 'oauth-based'
}
@@ -3877,6 +3947,7 @@ watch(
geminiOAuth.resetState()
antigravityOAuth.resetState()
grokOAuth.resetState()
}
)
@@ -4308,6 +4379,7 @@ const resetForm = () => {
openaiOAuth.resetState()
geminiOAuth.resetState()
antigravityOAuth.resetState()
grokOAuth.resetState()
oauthFlowRef.value?.reset()
antigravityMixedChannelConfirmed.value = false
clearMixedChannelDialog()
@@ -4702,6 +4774,7 @@ const goBackToBasicInfo = () => {
openaiOAuth.resetState()
geminiOAuth.resetState()
antigravityOAuth.resetState()
grokOAuth.resetState()
oauthFlowRef.value?.reset()
}
@@ -4717,6 +4790,8 @@ const handleGenerateUrl = async () => {
)
} else if (form.platform === 'antigravity') {
await antigravityOAuth.generateAuthUrl(form.proxy_id)
} else if (form.platform === 'grok') {
await grokOAuth.generateAuthUrl(form.proxy_id)
} else {
await oauth.generateAuthUrl(addMethod.value, form.proxy_id)
}
@@ -4727,6 +4802,8 @@ const handleValidateRefreshToken = (rt: string) => {
handleOpenAIValidateRT(rt)
} else if (form.platform === 'antigravity') {
handleAntigravityValidateRT(rt)
} else if (form.platform === 'grok') {
handleGrokValidateRT(rt)
}
}
@@ -4790,6 +4867,17 @@ const createAccountAndFinish = async (
delete credentials.compact_model_mapping
}
}
if (platform === 'grok') {
if (!credentials.base_url) {
credentials.base_url = apiKeyBaseUrl.value.trim() || 'https://api.x.ai/v1'
}
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
if (modelMapping) {
credentials.model_mapping = modelMapping
} else {
delete credentials.model_mapping
}
}
await doCreateAccount({
name: form.name,
notes: form.notes,
@@ -4808,6 +4896,95 @@ const createAccountAndFinish = async (
})
}
// Grok 手动 RT 批量验证和创建
const handleGrokValidateRT = async (refreshTokenInput: string) => {
if (!refreshTokenInput.trim()) return
const refreshTokens = refreshTokenInput
.split('\n')
.map((rt) => rt.trim())
.filter((rt) => rt)
if (refreshTokens.length === 0) {
grokOAuth.error.value = t('admin.accounts.oauth.grok.pleaseEnterRefreshToken')
return
}
grokOAuth.loading.value = true
grokOAuth.error.value = ''
let successCount = 0
let failedCount = 0
const errors: string[] = []
try {
for (let i = 0; i < refreshTokens.length; i++) {
try {
const tokenInfo = await grokOAuth.validateRefreshToken(refreshTokens[i], form.proxy_id)
if (!tokenInfo) {
failedCount++
errors.push(`#${i + 1}: ${grokOAuth.error.value || 'Validation failed'}`)
grokOAuth.error.value = ''
continue
}
const credentials = grokOAuth.buildCredentials(tokenInfo)
const extra = grokOAuth.buildExtraInfo(tokenInfo)
const accountName = refreshTokens.length > 1 ? `${form.name || tokenInfo.email || 'Grok OAuth Account'} #${i + 1}` : (form.name || tokenInfo.email || 'Grok OAuth Account')
const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value)
if (modelMapping) {
credentials.model_mapping = modelMapping
}
if (!applyTempUnschedConfig(credentials)) {
return
}
await adminAPI.accounts.create({
name: accountName,
notes: form.notes,
platform: 'grok',
type: 'oauth',
credentials,
extra,
proxy_id: form.proxy_id,
concurrency: form.concurrency,
load_factor: form.load_factor ?? undefined,
priority: form.priority,
rate_multiplier: form.rate_multiplier,
group_ids: form.group_ids,
expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value
})
successCount++
} catch (error: any) {
failedCount++
const errMsg = error.response?.data?.detail || error.message || 'Unknown error'
errors.push(`#${i + 1}: ${errMsg}`)
}
}
if (successCount > 0 && failedCount === 0) {
appStore.showSuccess(
refreshTokens.length > 1
? t('admin.accounts.oauth.batchSuccess', { count: successCount })
: t('admin.accounts.accountCreated')
)
emit('created')
handleClose()
} else if (successCount > 0) {
appStore.showWarning(t('admin.accounts.oauth.batchPartialSuccess', { success: successCount, failed: failedCount }))
grokOAuth.error.value = errors.join('\n')
emit('created')
} else {
grokOAuth.error.value = errors.join('\n')
appStore.showError(t('admin.accounts.oauth.batchFailed'))
}
} finally {
grokOAuth.loading.value = false
}
}
// OpenAI OAuth 授权码兑换
const handleOpenAIExchange = async (authCode: string) => {
const oauthClient = openaiOAuth
@@ -5341,6 +5518,41 @@ const handleAntigravityExchange = async (authCode: string) => {
}
}
// Grok OAuth 授权码兑换
const handleGrokExchange = async (authCode: string) => {
if (!authCode.trim() || !grokOAuth.sessionId.value) return
grokOAuth.loading.value = true
grokOAuth.error.value = ''
try {
const stateFromInput = oauthFlowRef.value?.oauthState || ''
const stateToUse = stateFromInput || grokOAuth.state.value
if (!stateToUse) {
grokOAuth.error.value = t('admin.accounts.oauth.authFailed')
appStore.showError(grokOAuth.error.value)
return
}
const tokenInfo = await grokOAuth.exchangeAuthCode({
code: authCode.trim(),
sessionId: grokOAuth.sessionId.value,
state: stateToUse,
proxyId: form.proxy_id
})
if (!tokenInfo) return
const credentials = grokOAuth.buildCredentials(tokenInfo)
const extra = grokOAuth.buildExtraInfo(tokenInfo)
await createAccountAndFinish('grok', 'oauth', credentials, extra)
} catch (error: any) {
grokOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(grokOAuth.error.value)
} finally {
grokOAuth.loading.value = false
}
}
// Anthropic OAuth 授权码兑换
const handleAnthropicExchange = async (authCode: string) => {
if (!authCode.trim() || !oauth.sessionId.value) return
@@ -5441,6 +5653,8 @@ const handleExchangeCode = async () => {
return handleGeminiExchange(authCode)
case 'antigravity':
return handleAntigravityExchange(authCode)
case 'grok':
return handleGrokExchange(authCode)
default:
return handleAnthropicExchange(authCode)
}
@@ -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>
@@ -181,7 +181,7 @@ const normalizedPlatforms = computed(() => {
)
})
const upstreamSyncPlatforms = new Set(['anthropic', 'openai', 'gemini', 'antigravity'])
const upstreamSyncPlatforms = new Set(['anthropic', 'openai', 'gemini', 'antigravity', 'grok'])
const canSyncUpstream = computed(() => {
if (props.accountId) {
if (normalizedPlatforms.value.length === 0) return true
@@ -616,9 +616,9 @@
<p class="text-sm text-blue-700 dark:text-blue-300">
{{ oauthOpenUrlDesc }}
</p>
<!-- OpenAI Important Notice -->
<!-- Local callback notice -->
<div
v-if="isOpenAI"
v-if="showLocalCallbackNotice"
class="mt-2 rounded border border-amber-300 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-900/30"
>
<p
@@ -776,13 +776,14 @@ const emit = defineEmits<{
const { t } = useI18n()
const isOpenAI = computed(() => props.platform === 'openai')
const showLocalCallbackNotice = computed(() => props.platform === 'openai' || props.platform === 'grok')
// Get translation key based on platform
const getOAuthKey = (key: string) => {
if (props.platform === 'openai') return `admin.accounts.oauth.openai.${key}`
if (props.platform === 'gemini') return `admin.accounts.oauth.gemini.${key}`
if (props.platform === 'antigravity') return `admin.accounts.oauth.antigravity.${key}`
if (props.platform === 'grok') return `admin.accounts.oauth.grok.${key}`
return `admin.accounts.oauth.${key}`
}
@@ -801,6 +802,7 @@ const oauthAuthCodeHint = computed(() => t(getOAuthKey('authCodeHint')))
const oauthImportantNotice = computed(() => {
if (props.platform === 'openai') return t('admin.accounts.oauth.openai.importantNotice')
if (props.platform === 'antigravity') return t('admin.accounts.oauth.antigravity.importantNotice')
if (props.platform === 'grok') return t('admin.accounts.oauth.grok.importantNotice')
return ''
})
@@ -853,20 +855,20 @@ watch(inputMethod, (newVal) => {
emit('update:inputMethod', newVal)
})
// Auto-extract code from callback URL (OpenAI/Gemini/Antigravity)
// Auto-extract code from callback URL (OpenAI/Gemini/Antigravity/Grok)
// e.g., http://localhost:8085/callback?code=xxx...&state=...
watch(authCodeInput, (newVal) => {
if (props.platform !== 'openai' && props.platform !== 'gemini' && props.platform !== 'antigravity') return
if (props.platform !== 'openai' && props.platform !== 'gemini' && props.platform !== 'antigravity' && props.platform !== 'grok') return
const trimmed = newVal.trim()
// Check if it looks like a URL with code parameter
if (trimmed.includes('?') && trimmed.includes('code=')) {
if (trimmed.includes('code=')) {
try {
// Try to parse as URL
const url = new URL(trimmed)
const url = trimmed.includes('?') ? new URL(trimmed) : new URL(`http://localhost/callback?${trimmed.replace(/^\?/, '')}`)
const code = url.searchParams.get('code')
const stateParam = url.searchParams.get('state')
if ((props.platform === 'openai' || props.platform === 'gemini' || props.platform === 'antigravity') && stateParam) {
if ((props.platform === 'openai' || props.platform === 'gemini' || props.platform === 'antigravity' || props.platform === 'grok') && stateParam) {
oauthState.value = stateParam
}
if (code && code !== trimmed) {
@@ -877,7 +879,7 @@ watch(authCodeInput, (newVal) => {
// If URL parsing fails, try regex extraction
const match = trimmed.match(/[?&]code=([^&]+)/)
const stateMatch = trimmed.match(/[?&]state=([^&]+)/)
if ((props.platform === 'openai' || props.platform === 'gemini' || props.platform === 'antigravity') && stateMatch && stateMatch[1]) {
if ((props.platform === 'openai' || props.platform === 'gemini' || props.platform === 'antigravity' || props.platform === 'grok') && stateMatch && stateMatch[1]) {
oauthState.value = stateMatch[1]
}
if (match && match[1] && match[1] !== trimmed) {
@@ -489,7 +489,8 @@ const platformOptions = [
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'antigravity', label: 'Antigravity' }
{ value: 'antigravity', label: 'Antigravity' },
{ value: 'grok', label: 'Grok' }
]
// Load rules when dialog opens
@@ -25,7 +25,7 @@ const updateType = (value: string | number | boolean | null) => { emit('update:f
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) }
const updatePrivacyMode = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, privacy_mode: value }) }
const updateGroup = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, group: value }) }
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }])
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }, { value: 'grok', label: 'Grok' }])
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }, { value: 'bedrock', label: 'AWS Bedrock' }])
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }, { value: 'temp_unschedulable', label: t('admin.accounts.status.tempUnschedulable') }, { value: 'unschedulable', label: t('admin.accounts.status.unschedulable') }])
const privacyOpts = computed(() => [
@@ -20,7 +20,9 @@
? 'from-blue-500 to-blue-600'
: isAntigravity
? 'from-purple-500 to-purple-600'
: 'from-orange-500 to-orange-600'
: isGrok
? 'from-slate-600 to-cyan-600'
: 'from-orange-500 to-orange-600'
]"
>
<Icon name="sparkles" size="md" class="text-white" />
@@ -37,7 +39,9 @@
? t('admin.accounts.geminiAccount')
: isAntigravity
? t('admin.accounts.antigravityAccount')
: t('admin.accounts.claudeCodeAccount')
: isGrok
? t('admin.accounts.grokAccount')
: t('admin.accounts.claudeCodeAccount')
}}
</span>
</div>
@@ -128,7 +132,7 @@
:show-cookie-option="isAnthropic"
:allow-multiple="false"
:method-label="t('admin.accounts.inputMethod')"
:platform="isOpenAI ? 'openai' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
:platform="isOpenAI ? 'openai' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : isGrok ? 'grok' : 'anthropic'"
:show-project-id="isGemini && geminiOAuthType === 'code_assist'"
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
@@ -192,6 +196,7 @@ import {
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
import { useGrokOAuth } from '@/composables/useGrokOAuth'
import type { Account } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Icon from '@/components/icons/Icon.vue'
@@ -227,6 +232,7 @@ const claudeOAuth = useAccountOAuth()
const openaiOAuth = useOpenAIOAuth()
const geminiOAuth = useGeminiOAuth()
const antigravityOAuth = useAntigravityOAuth()
const grokOAuth = useGrokOAuth()
// Refs
const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
@@ -241,37 +247,42 @@ const isOpenAILike = computed(() => isOpenAI.value)
const isGemini = computed(() => props.account?.platform === 'gemini')
const isAnthropic = computed(() => props.account?.platform === 'anthropic')
const isAntigravity = computed(() => props.account?.platform === 'antigravity')
const isGrok = computed(() => props.account?.platform === 'grok')
// Computed - current OAuth state based on platform
const currentAuthUrl = computed(() => {
if (isOpenAILike.value) return openaiOAuth.authUrl.value
if (isGemini.value) return geminiOAuth.authUrl.value
if (isAntigravity.value) return antigravityOAuth.authUrl.value
if (isGrok.value) return grokOAuth.authUrl.value
return claudeOAuth.authUrl.value
})
const currentSessionId = computed(() => {
if (isOpenAILike.value) return openaiOAuth.sessionId.value
if (isGemini.value) return geminiOAuth.sessionId.value
if (isAntigravity.value) return antigravityOAuth.sessionId.value
if (isGrok.value) return grokOAuth.sessionId.value
return claudeOAuth.sessionId.value
})
const currentLoading = computed(() => {
if (isOpenAILike.value) return openaiOAuth.loading.value
if (isGemini.value) return geminiOAuth.loading.value
if (isAntigravity.value) return antigravityOAuth.loading.value
if (isGrok.value) return grokOAuth.loading.value
return claudeOAuth.loading.value
})
const currentError = computed(() => {
if (isOpenAILike.value) return openaiOAuth.error.value
if (isGemini.value) return geminiOAuth.error.value
if (isAntigravity.value) return antigravityOAuth.error.value
if (isGrok.value) return grokOAuth.error.value
return claudeOAuth.error.value
})
// Computed
const isManualInputMethod = computed(() => {
// OpenAI/Gemini/Antigravity always use manual input (no cookie auth option)
return isOpenAILike.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
return isOpenAILike.value || isGemini.value || isAntigravity.value || isGrok.value || oauthFlowRef.value?.inputMethod === 'manual'
})
const canExchangeCode = computed(() => {
@@ -316,6 +327,7 @@ const resetState = () => {
openaiOAuth.resetState()
geminiOAuth.resetState()
antigravityOAuth.resetState()
grokOAuth.resetState()
oauthFlowRef.value?.reset()
}
@@ -335,6 +347,8 @@ const handleGenerateUrl = async () => {
await geminiOAuth.generateAuthUrl(props.account.proxy_id, projectId, geminiOAuthType.value, tierId)
} else if (isAntigravity.value) {
await antigravityOAuth.generateAuthUrl(props.account.proxy_id)
} else if (isGrok.value) {
await grokOAuth.generateAuthUrl(props.account.proxy_id)
} else {
await claudeOAuth.generateAuthUrl(addMethod.value, props.account.proxy_id)
}
@@ -449,6 +463,39 @@ const handleExchangeCode = async () => {
antigravityOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(antigravityOAuth.error.value)
}
} else if (isGrok.value) {
const sessionId = grokOAuth.sessionId.value
if (!sessionId) return
const stateFromInput = oauthFlowRef.value?.oauthState || ''
const stateToUse = stateFromInput || grokOAuth.state.value
if (!stateToUse) return
const tokenInfo = await grokOAuth.exchangeAuthCode({
code: authCode.trim(),
sessionId,
state: stateToUse,
proxyId: props.account.proxy_id
})
if (!tokenInfo) return
const credentials = grokOAuth.buildCredentials(tokenInfo)
const extra = grokOAuth.buildExtraInfo(tokenInfo)
try {
const updatedAccount = await adminAPI.accounts.applyOAuthCredentials(props.account.id, {
type: 'oauth',
credentials,
extra
})
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized', updatedAccount)
handleClose()
} catch (error: any) {
grokOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(grokOAuth.error.value)
}
} else {
// Claude OAuth flow
const sessionId = claudeOAuth.sessionId.value
@@ -196,6 +196,7 @@ export function getPlatformTagClass(platform: string): string {
case 'openai': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
case 'gemini': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
case 'antigravity': return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
case 'grok': return 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300'
default: return 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'
}
}
@@ -207,6 +208,7 @@ export function getPlatformTextClass(platform: string): string {
case 'openai': return 'text-emerald-700 dark:text-emerald-400'
case 'gemini': return 'text-blue-700 dark:text-blue-400'
case 'antigravity': return 'text-purple-700 dark:text-purple-400'
case 'grok': return 'text-slate-700 dark:text-slate-300'
default: return ''
}
}
@@ -301,7 +301,7 @@
<span class="font-medium text-pink-300">${{ tooltipData.image_output_cost.toFixed(6) }}</span>
</div>
<!-- Token billing: show unit prices per 1M tokens -->
<template v-if="!tooltipData?.billing_mode || tooltipData.billing_mode === BILLING_MODE_TOKEN">
<template v-if="tooltipData && !isImageUsage(tooltipData) && (!tooltipData.billing_mode || tooltipData.billing_mode === BILLING_MODE_TOKEN)">
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
@@ -315,7 +315,7 @@
<span class="font-medium text-pink-300">{{ formatTokenPricePerMillion(tooltipData.image_output_cost ?? 0, tooltipData.image_output_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
</template>
<template v-else-if="isImageUsage(tooltipData)">
<template v-else-if="tooltipData && isImageUsage(tooltipData)">
<div class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageCount') }}</span>
<span class="font-medium text-white">{{ tooltipData.image_count }}{{ t('usage.imageUnit') }}</span>
@@ -128,7 +128,7 @@ const emit = defineEmits(['close', 'success'])
const { t } = useI18n()
const appStore = useAppStore()
const PLATFORMS: PlatformQuotaPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity']
const PLATFORMS: PlatformQuotaPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity', 'grok']
interface QuotaRow {
platform: PlatformQuotaPlatform
@@ -79,13 +79,14 @@ describe('UserPlatformQuotaModal', () => {
expect(apiMocks.getPlatformQuotas).toHaveBeenCalledWith(99)
})
it('空数据渲染 4 个 platform 行', async () => {
it('空数据渲染 5 个 platform 行', async () => {
const w = await mountAndOpen()
const html = w.html()
expect(html).toContain('anthropic')
expect(html).toContain('openai')
expect(html).toContain('gemini')
expect(html).toContain('antigravity')
expect(html).toContain('grok')
})
it('已有数据正确填充 limit input', async () => {
@@ -97,13 +98,13 @@ describe('UserPlatformQuotaModal', () => {
})
const w = await mountAndOpen()
const inputs = w.findAll('input[type=number]')
// 4 platforms × 3 windows = 12 inputs
expect(inputs.length).toBe(12)
// 5 platforms × 3 windows = 15 inputs
expect(inputs.length).toBe(15)
// 第一个 input 是 anthropic.daily = 10
expect((inputs[0].element as HTMLInputElement).value).toBe('10')
})
it('保存提交完整 4 platform payload', async () => {
it('保存提交完整 5 platform payload', async () => {
apiMocks.getPlatformQuotas.mockResolvedValueOnce({
platform_quotas: [
{ platform: 'openai', daily_limit_usd: null, weekly_limit_usd: 20, monthly_limit_usd: null,
@@ -120,7 +121,7 @@ describe('UserPlatformQuotaModal', () => {
expect(apiMocks.updatePlatformQuotas).toHaveBeenCalledTimes(1)
const [uid, payload] = apiMocks.updatePlatformQuotas.mock.calls[0]
expect(uid).toBe(99)
expect(payload).toHaveLength(4) // 4 platforms always submitted
expect(payload).toHaveLength(5) // 5 platforms always submitted
const openai = payload.find((p: any) => p.platform === 'openai')
expect(openai.weekly_limit_usd).toBe(20)
})
@@ -185,7 +185,7 @@ const displayGroupStats = computed(() => {
if (!props.groupStats?.length) return []
const metricKey = props.metric === 'actual_cost' ? 'actual_cost' : 'total_tokens'
return [...props.groupStats].sort((a, b) => b[metricKey] - a[metricKey])
return [...props.groupStats].sort((a, b) => toFiniteNumber(b[metricKey]) - toFiniteNumber(a[metricKey]))
})
const chartData = computed(() => {
@@ -195,7 +195,7 @@ const chartData = computed(() => {
labels: displayGroupStats.value.map((g) => g.group_name || String(g.group_id)),
datasets: [
{
data: displayGroupStats.value.map((g) => props.metric === 'actual_cost' ? g.actual_cost : g.total_tokens),
data: displayGroupStats.value.map((g) => toFiniteNumber(props.metric === 'actual_cost' ? g.actual_cost : g.total_tokens)),
backgroundColor: chartColors.slice(0, displayGroupStats.value.length),
borderWidth: 0
}
@@ -238,17 +238,23 @@ const formatTokens = (value: number): string => {
}
const formatNumber = (value: number): string => {
return value.toLocaleString()
return toFiniteNumber(value).toLocaleString()
}
const formatCost = (value: number): string => {
if (value >= 1000) {
return (value / 1000).toFixed(2) + 'K'
} else if (value >= 1) {
return value.toFixed(2)
} else if (value >= 0.01) {
return value.toFixed(3)
const toFiniteNumber = (value: unknown): number => {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : 0
}
const formatCost = (value: number | null | undefined): string => {
const safeValue = toFiniteNumber(value)
if (safeValue >= 1000) {
return (safeValue / 1000).toFixed(2) + 'K'
} else if (safeValue >= 1) {
return safeValue.toFixed(2)
} else if (safeValue >= 0.01) {
return safeValue.toFixed(3)
}
return value.toFixed(4)
return safeValue.toFixed(4)
}
</script>
@@ -354,7 +354,7 @@ const displayModelStats = computed(() => {
if (!sourceStats?.length) return []
const metricKey = props.metric === 'actual_cost' ? 'actual_cost' : 'total_tokens'
return [...sourceStats].sort((a, b) => b[metricKey] - a[metricKey])
return [...sourceStats].sort((a, b) => toFiniteNumber(b[metricKey]) - toFiniteNumber(a[metricKey]))
})
const chartData = computed(() => {
@@ -364,7 +364,7 @@ const chartData = computed(() => {
labels: displayModelStats.value.map((m) => m.model),
datasets: [
{
data: displayModelStats.value.map((m) => props.metric === 'actual_cost' ? m.actual_cost : m.total_tokens),
data: displayModelStats.value.map((m) => toFiniteNumber(props.metric === 'actual_cost' ? m.actual_cost : m.total_tokens)),
backgroundColor: chartColors.slice(0, displayModelStats.value.length),
borderWidth: 0
}
@@ -376,7 +376,7 @@ const rankingChartData = computed(() => {
if (!props.rankingItems?.length) return null
const labels = props.rankingItems.map((item, index) => `#${index + 1} ${getRankingUserLabel(item)}`)
const data = props.rankingItems.map((item) => item.actual_cost)
const data = props.rankingItems.map((item) => toFiniteNumber(item.actual_cost))
const backgroundColor = chartColors.slice(0, props.rankingItems.length)
if (otherRankingItem.value) {
@@ -400,9 +400,9 @@ const rankingChartData = computed(() => {
const otherRankingItem = computed<RankingDisplayItem | null>(() => {
if (!props.rankingItems?.length) return null
const rankedActualCost = props.rankingItems.reduce((sum, item) => sum + item.actual_cost, 0)
const rankedRequests = props.rankingItems.reduce((sum, item) => sum + item.requests, 0)
const rankedTokens = props.rankingItems.reduce((sum, item) => sum + item.tokens, 0)
const rankedActualCost = props.rankingItems.reduce((sum, item) => sum + toFiniteNumber(item.actual_cost), 0)
const rankedRequests = props.rankingItems.reduce((sum, item) => sum + toFiniteNumber(item.requests), 0)
const rankedTokens = props.rankingItems.reduce((sum, item) => sum + toFiniteNumber(item.tokens), 0)
const otherActualCost = Math.max((props.rankingTotalActualCost || 0) - rankedActualCost, 0)
const otherRequests = Math.max((props.rankingTotalRequests || 0) - rankedRequests, 0)
@@ -482,7 +482,7 @@ const formatTokens = (value: number): string => {
}
const formatNumber = (value: number): string => {
return value.toLocaleString()
return toFiniteNumber(value).toLocaleString()
}
const getRankingUserLabel = (item: UserSpendingRankingItem): string => {
@@ -495,14 +495,20 @@ const getRankingRowLabel = (item: RankingDisplayItem): string => {
return getRankingUserLabel(item)
}
const formatCost = (value: number): string => {
if (value >= 1000) {
return (value / 1000).toFixed(2) + 'K'
} else if (value >= 1) {
return value.toFixed(2)
} else if (value >= 0.01) {
return value.toFixed(3)
const toFiniteNumber = (value: unknown): number => {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : 0
}
const formatCost = (value: number | null | undefined): string => {
const safeValue = toFiniteNumber(value)
if (safeValue >= 1000) {
return (safeValue / 1000).toFixed(2) + 'K'
} else if (safeValue >= 1) {
return safeValue.toFixed(2)
} else if (safeValue >= 0.01) {
return safeValue.toFixed(3)
}
return value.toFixed(4)
return safeValue.toFixed(4)
}
</script>
@@ -19,6 +19,12 @@
<svg v-else-if="platform === 'antigravity'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z" />
</svg>
<!-- Grok/xAI logo mark -->
<svg v-else-if="platform === 'grok'" :class="sizeClass" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 18 18 4" />
<path stroke-linecap="round" stroke-linejoin="round" d="M8 5h11v11" />
<path stroke-linecap="round" stroke-linejoin="round" d="M5 6l13 13" />
</svg>
<!-- Fallback: generic platform icon -->
<svg v-else :class="sizeClass" fill="currentColor" viewBox="0 0 24 24">
<path
@@ -76,6 +76,7 @@ const platformLabel = computed(() => {
if (props.platform === 'anthropic') return 'Anthropic'
if (props.platform === 'openai') return 'OpenAI'
if (props.platform === 'antigravity') return 'Antigravity'
if (props.platform === 'grok') return 'Grok'
return 'Gemini'
})
@@ -126,6 +127,9 @@ const platformClass = computed(() => {
if (props.platform === 'antigravity') {
return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
}
if (props.platform === 'grok') {
return 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300'
}
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
})
@@ -139,6 +143,9 @@ const typeClass = computed(() => {
if (props.platform === 'antigravity') {
return 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400'
}
if (props.platform === 'grok') {
return 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300'
}
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
})
@@ -34,7 +34,7 @@ import type { PlatformQuotaItem, PlatformQuotaPlatform } from '@/api/admin/users
const props = defineProps<{ quotas?: PlatformQuotaItem[] }>()
const { t } = useI18n()
const PLATFORM_ORDER: PlatformQuotaPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity']
const PLATFORM_ORDER: PlatformQuotaPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity', 'grok']
// 仅展示「至少一档限额非空」的平台(配额列,非用量列)
const configured = computed(() => {
@@ -278,7 +278,7 @@ const platformCards = computed<FusedPlatformCard[]>(() => {
// 无需显式排除;__other__ 由下方差值补差逻辑单独追加。
const platforms = new Set<string>([...byPlat.keys(), ...byQuota.keys()])
const PLATFORM_ORDER = ['anthropic', 'openai', 'gemini', 'antigravity']
const PLATFORM_ORDER = ['anthropic', 'openai', 'gemini', 'antigravity', 'grok']
const cards: FusedPlatformCard[] = []
for (const p of platforms) {