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
@@ -9,12 +9,13 @@ import {
type DefaultPlatformQuotasMap,
} from "@/api/admin/settings";
/** 全 null 的 4 平台 map,用于断言归一化默认值 */
/** 全 null 的 5 平台 map,用于断言归一化默认值 */
const allNullQuotas: DefaultPlatformQuotasMap = {
anthropic: { daily: null, weekly: null, monthly: null },
openai: { daily: null, weekly: null, monthly: null },
gemini: { daily: null, weekly: null, monthly: null },
antigravity: { daily: null, weekly: null, monthly: null },
grok: { daily: null, weekly: null, monthly: null },
}
describe("admin settings auth source defaults helpers", () => {
@@ -236,11 +237,12 @@ describe("normalizePlatformQuotasMap", () => {
expect(result.openai).toEqual({ daily: null, weekly: null, monthly: null });
expect(result.gemini).toEqual({ daily: null, weekly: null, monthly: null });
expect(result.antigravity).toEqual({ daily: null, weekly: null, monthly: null });
expect(result.grok).toEqual({ daily: null, weekly: null, monthly: null });
});
it("无参数时返回全 4 平台全 null", () => {
it("无参数时返回全 5 平台全 null", () => {
const result = normalizePlatformQuotasMap();
expect(Object.keys(result)).toHaveLength(4);
expect(Object.keys(result)).toHaveLength(5);
for (const v of Object.values(result)) {
expect(v).toEqual({ daily: null, weekly: null, monthly: null });
}
@@ -288,7 +290,7 @@ describe("sanitizePlatformQuotasMap", () => {
it("缺失平台填充为全 null", () => {
const result = sanitizePlatformQuotasMap({});
expect(Object.keys(result)).toHaveLength(4);
expect(Object.keys(result)).toHaveLength(5);
for (const v of Object.values(result)) {
expect(v).toEqual({ daily: null, weekly: null, monthly: null });
}
+121
View File
@@ -0,0 +1,121 @@
/**
* Admin Grok/xAI API endpoints
* Handles xAI OAuth flows for administrators.
*/
import { apiClient } from '../client'
export interface GrokAuthUrlResponse {
auth_url: string
session_id: string
state: string
}
export interface GrokAuthUrlRequest {
proxy_id?: number
redirect_uri?: string
}
export interface GrokExchangeCodeRequest {
session_id: string
state: string
code: string
proxy_id?: number
redirect_uri?: string
}
export interface GrokTokenInfo {
access_token?: string
refresh_token?: string
token_type?: string
id_token?: string
expires_at?: number | string
expires_in?: number
scope?: string
client_id?: string
email?: string
subscription_tier?: string
entitlement_status?: string
[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>
headers_observed: boolean
observation_source?: string
last_probe_at?: string
last_headers_seen_at?: 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> {
const { data } = await apiClient.post<GrokAuthUrlResponse>(
'/admin/grok/oauth/auth-url',
payload
)
return data
}
export async function exchangeCode(payload: GrokExchangeCodeRequest): Promise<GrokTokenInfo> {
const { data } = await apiClient.post<GrokTokenInfo>(
'/admin/grok/oauth/exchange-code',
payload
)
return data
}
export async function refreshGrokToken(
refreshToken: string,
proxyId?: number | null
): Promise<GrokTokenInfo> {
const payload: Record<string, unknown> = { refresh_token: refreshToken }
if (proxyId) payload.proxy_id = proxyId
const { data } = await apiClient.post<GrokTokenInfo>(
'/admin/grok/oauth/refresh-token',
payload
)
return data
}
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 }
+3
View File
@@ -17,6 +17,7 @@ import subscriptionsAPI from './subscriptions'
import usageAPI from './usage'
import geminiAPI from './gemini'
import antigravityAPI from './antigravity'
import grokAPI from './grok'
import userAttributesAPI from './userAttributes'
import opsAPI from './ops'
import errorPassthroughAPI from './errorPassthrough'
@@ -51,6 +52,7 @@ export const adminAPI = {
usage: usageAPI,
gemini: geminiAPI,
antigravity: antigravityAPI,
grok: grokAPI,
userAttributes: userAttributesAPI,
ops: opsAPI,
errorPassthrough: errorPassthroughAPI,
@@ -83,6 +85,7 @@ export {
usageAPI,
geminiAPI,
antigravityAPI,
grokAPI,
userAttributesAPI,
opsAPI,
errorPassthroughAPI,
+2 -2
View File
@@ -17,7 +17,7 @@ export interface DefaultSubscriptionSetting {
}
// ── 平台限额类型 ──────────────────────────────────────────────────
export type PlatformType = "anthropic" | "openai" | "gemini" | "antigravity"
export type PlatformType = "anthropic" | "openai" | "gemini" | "antigravity" | "grok"
export type QuotaWindowType = "daily" | "weekly" | "monthly"
/** 单平台三档限额;null = 不限制,undefined = 未填(等价 null) */
@@ -30,7 +30,7 @@ export interface PlatformQuotaLimits {
/** 全平台默认限额 map(key = PlatformType) */
export type DefaultPlatformQuotasMap = Partial<Record<PlatformType, PlatformQuotaLimits>>
const PLATFORMS: PlatformType[] = ["anthropic", "openai", "gemini", "antigravity"]
const PLATFORMS: PlatformType[] = ["anthropic", "openai", "gemini", "antigravity", "grok"]
/** 归一化为全 4 平台 × 3 窗口(缺失填 null),供模板非空绑定 */
export function normalizePlatformQuotasMap(input?: DefaultPlatformQuotasMap | null): DefaultPlatformQuotasMap {
+1 -1
View File
@@ -307,7 +307,7 @@ export async function bindUserAuthIdentity(
/**
* Platform quota types
*/
export type PlatformQuotaPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
export type PlatformQuotaPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok'
export type PlatformQuotaWindow = 'daily' | 'weekly' | 'monthly'
export interface PlatformQuotaItem {
@@ -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) {
+142
View File
@@ -0,0 +1,142 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { GrokTokenInfo } from '@/api/admin/grok'
export function useGrokOAuth() {
const appStore = useAppStore()
const { t } = useI18n()
const authUrl = ref('')
const sessionId = ref('')
const state = ref('')
const loading = ref(false)
const error = ref('')
const resetState = () => {
authUrl.value = ''
sessionId.value = ''
state.value = ''
loading.value = false
error.value = ''
}
const generateAuthUrl = async (proxyId: number | null | undefined): Promise<boolean> => {
loading.value = true
authUrl.value = ''
sessionId.value = ''
state.value = ''
error.value = ''
try {
const payload: Record<string, unknown> = {}
if (proxyId) payload.proxy_id = proxyId
const response = await adminAPI.grok.generateAuthUrl(payload)
authUrl.value = response.auth_url
sessionId.value = response.session_id
state.value = response.state
return true
} catch (err: any) {
error.value = err.response?.data?.detail || t('admin.accounts.oauth.grok.failedToGenerateUrl')
appStore.showError(error.value)
return false
} finally {
loading.value = false
}
}
const exchangeAuthCode = async (params: {
code: string
sessionId: string
state: string
proxyId?: number | null
}): Promise<GrokTokenInfo | null> => {
const code = params.code?.trim()
if (!code || !params.sessionId || !params.state) {
error.value = t('admin.accounts.oauth.grok.missingExchangeParams')
return null
}
loading.value = true
error.value = ''
try {
const payload: Record<string, unknown> = {
session_id: params.sessionId,
state: params.state,
code
}
if (params.proxyId) payload.proxy_id = params.proxyId
return await adminAPI.grok.exchangeCode(payload as any)
} catch (err: any) {
error.value = err.response?.data?.detail || t('admin.accounts.oauth.grok.failedToExchangeCode')
appStore.showError(error.value)
return null
} finally {
loading.value = false
}
}
const validateRefreshToken = async (
refreshToken: string,
proxyId?: number | null
): Promise<GrokTokenInfo | null> => {
if (!refreshToken.trim()) {
error.value = t('admin.accounts.oauth.grok.pleaseEnterRefreshToken')
return null
}
loading.value = true
error.value = ''
try {
return await adminAPI.grok.refreshGrokToken(refreshToken.trim(), proxyId)
} catch (err: any) {
error.value = err.response?.data?.detail || t('admin.accounts.oauth.grok.failedToValidateRT')
return null
} finally {
loading.value = false
}
}
const buildCredentials = (tokenInfo: GrokTokenInfo): Record<string, unknown> => {
const credentials: Record<string, unknown> = {
access_token: tokenInfo.access_token,
token_type: tokenInfo.token_type,
expires_at: tokenInfo.expires_at,
client_id: tokenInfo.client_id,
scope: tokenInfo.scope,
email: tokenInfo.email,
subscription_tier: tokenInfo.subscription_tier,
entitlement_status: tokenInfo.entitlement_status
}
if (tokenInfo.refresh_token) credentials.refresh_token = tokenInfo.refresh_token
if (tokenInfo.id_token) credentials.id_token = tokenInfo.id_token
return Object.fromEntries(Object.entries(credentials).filter(([, value]) => value !== undefined && value !== ''))
}
const buildExtraInfo = (tokenInfo: GrokTokenInfo): Record<string, unknown> => {
const extra: Record<string, unknown> = {}
if (tokenInfo.email) extra.email = tokenInfo.email
if (tokenInfo.subscription_tier) extra.subscription_tier = tokenInfo.subscription_tier
if (tokenInfo.entitlement_status) extra.entitlement_status = tokenInfo.entitlement_status
return extra
}
return {
authUrl,
sessionId,
state,
loading,
error,
resetState,
generateAuthUrl,
exchangeAuthCode,
validateRefreshToken,
buildCredentials,
buildExtraInfo
}
}
+21 -5
View File
@@ -131,10 +131,16 @@ const metaModels = [
// xAI Grok
const xaiModels = [
'grok-4', 'grok-4-0709',
'grok-3-beta', 'grok-3-mini-beta', 'grok-3-fast-beta',
'grok-2', 'grok-2-vision', 'grok-2-image',
'grok-beta', 'grok-vision-beta'
'grok-4.3',
'grok-build-0.1',
'grok-4.20-0309-reasoning',
'grok-4.20-0309-non-reasoning',
'grok-4.20-multi-agent-0309',
'grok',
'grok-latest',
'grok-build',
'grok-4.20-reasoning',
'grok-4.20-non-reasoning'
]
// Cohere
@@ -273,6 +279,14 @@ const geminiPresetMappings = [
{ label: '3.1 Image', from: 'gemini-3.1-flash-image', to: 'gemini-3.1-flash-image', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' }
]
const grokPresetMappings = [
{ label: 'Grok 4.3', from: 'grok-4.3', to: 'grok-4.3', color: 'bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-800/50 dark:text-slate-300' },
{ label: 'Grok Latest', from: 'grok-latest', to: 'grok-4.3', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
{ label: 'Build 0.1', from: 'grok-build', to: 'grok-build-0.1', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
{ label: '4.20 Reasoning', from: 'grok-4.20-reasoning', to: 'grok-4.20-0309-reasoning', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: '4.20 Non Reasoning', from: 'grok-4.20-non-reasoning', to: 'grok-4.20-0309-non-reasoning', color: 'bg-violet-100 text-violet-700 hover:bg-violet-200 dark:bg-violet-900/30 dark:text-violet-400' }
]
// Antigravity 预设映射(支持通配符)
const antigravityPresetMappings = [
// Claude 通配符映射
@@ -371,7 +385,8 @@ export function getModelsByPlatform(platform: string): string[] {
case 'deepseek': return deepseekModels
case 'mistral': return mistralModels
case 'meta': return metaModels
case 'xai': return xaiModels
case 'xai':
case 'grok': return xaiModels
case 'cohere': return cohereModels
case 'yi': return yiModels
case 'moonshot': return moonshotModels
@@ -389,6 +404,7 @@ export function getModelsByPlatform(platform: string): string[] {
export function getPresetMappingsByPlatform(platform: string) {
if (platform === 'openai') return openaiPresetMappings
if (platform === 'gemini') return geminiPresetMappings
if (platform === 'grok' || platform === 'xai') return grokPresetMappings
if (platform === 'antigravity') return antigravityPresetMappings
if (platform === 'bedrock') return bedrockPresetMappings
return anthropicPresetMappings
@@ -3,6 +3,10 @@ import { getConfiguredTableDefaultPageSize, normalizeTablePageSize } from '@/uti
const STORAGE_KEY = 'table-page-size'
export function getPersistedPageSize(fallback = getConfiguredTableDefaultPageSize()): number {
if (typeof window !== 'undefined' && window.__APP_CONFIG__?.table_default_page_size !== undefined) {
return normalizeTablePageSize(getConfiguredTableDefaultPageSize())
}
if (typeof window !== 'undefined') {
try {
const stored = window.localStorage.getItem(STORAGE_KEY)
+44
View File
@@ -2219,6 +2219,7 @@ export default {
openai: 'OpenAI',
gemini: 'Gemini',
antigravity: 'Antigravity',
grok: 'Grok',
},
deleteConfirm:
"Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.",
@@ -3135,6 +3136,7 @@ export default {
googleOauth: 'Google OAuth',
codeAssist: 'Code Assist',
antigravityOauth: 'Antigravity OAuth',
grokOauth: 'Grok OAuth',
antigravityApikey: 'Connect via Base URL + API Key',
upstream: 'Upstream',
upstreamDesc: 'Connect via Base URL + API Key'
@@ -3493,6 +3495,10 @@ export default {
testModeCompact: 'Compact probe',
modelRestrictionDisabledByPassthrough: 'Automatic passthrough is enabled: model whitelist/mapping will not take effect.',
},
grok: {
baseUrlHint: 'Grok OAuth accounts forward to the official xAI API base URL.',
apiKeyHint: 'Grok subscription support uses OAuth refresh tokens; API keys are out of scope for this account type.'
},
anthropic: {
apiKeyPassthrough: 'Auto passthrough (auth only)',
apiKeyPassthroughDesc:
@@ -3831,6 +3837,31 @@ export default {
pleaseEnterRefreshToken: 'Please enter Refresh Token',
pleaseEnterSessionToken: 'Please enter Session Token'
},
grok: {
title: 'Grok Account Authorization',
followSteps: 'Follow these steps to authorize your xAI/Grok account:',
step1GenerateUrl: 'Generate the xAI authorization URL',
generateAuthUrl: 'Generate Auth URL',
step2OpenUrl: 'Open the URL in your browser and complete authorization',
openUrlDesc: 'Open the authorization URL in a new tab, sign in to xAI, and authorize API access.',
importantNotice: 'When the browser reaches the local callback URL, copy the full URL or the code query parameter back here.',
step3EnterCode: 'Enter Authorization URL or Code',
authCodeDesc: 'After authorization, paste the callback URL, query string, or authorization code:',
authCode: 'Authorization URL or Code',
authCodePlaceholder: 'Paste the full callback URL, ?code=... query string, or code value',
authCodeHint: 'Full callback URLs, query strings, and bare codes are accepted.',
refreshTokenAuth: 'Manual RT Input',
refreshTokenDesc: 'Enter existing xAI refresh token(s). Supports batch input, one per line.',
refreshTokenPlaceholder: 'Paste your xAI refresh token...\nSupports multiple, one per line',
validating: 'Validating...',
validateAndCreate: 'Validate & Create Account',
pleaseEnterRefreshToken: 'Please enter Refresh Token',
failedToGenerateUrl: 'Failed to generate Grok auth URL',
missingExchangeParams: 'Missing authorization code, state, or OAuth session',
failedToExchangeCode: 'Failed to exchange Grok authorization code',
failedToValidateRT: 'Failed to validate Grok refresh token',
oauthOnlyHint: 'Initial Grok support is OAuth subscription-backed Responses API text and reasoning traffic only.'
},
// Gemini specific
gemini: {
title: 'Gemini Account Authorization',
@@ -4052,6 +4083,7 @@ export default {
openaiAccount: 'OpenAI Account',
geminiAccount: 'Gemini Account',
antigravityAccount: 'Antigravity Account',
grokAccount: 'Grok Account',
inputMethod: 'Input Method',
reAuthorizedSuccess: 'Account re-authorized successfully',
// Test Modal
@@ -4126,6 +4158,18 @@ export default {
gemini3Flash: 'G3F',
gemini3Image: 'G31FI',
claude: 'Claude',
grokRequests: 'Req',
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',
grokLastStatus: 'Status {status}',
grokLastProbe: 'Probe {time}',
grokLastHeadersSeen: 'Headers {time}',
passiveSampled: 'Passive',
activeQuery: 'Query'
},
+44
View File
@@ -3315,6 +3315,7 @@ export default {
anthropic: 'Anthropic',
gemini: 'Gemini',
antigravity: 'Antigravity',
grok: 'Grok',
},
types: {
oauth: 'OAuth',
@@ -3323,6 +3324,7 @@ export default {
googleOauth: 'Google OAuth',
codeAssist: 'Code Assist',
antigravityOauth: 'Antigravity OAuth',
grokOauth: 'Grok OAuth',
antigravityApikey: '通过 Base URL + API Key 连接',
upstream: '对接上游',
upstreamDesc: '通过 Base URL + API Key 连接上游',
@@ -3406,6 +3408,18 @@ export default {
gemini3Flash: 'G3F',
gemini3Image: 'G31FI',
claude: 'Claude',
grokRequests: '请求',
grokTokens: 'Token',
grokUnknown: 'Grok 配额需等待首次上游响应返回 xAI rate-limit 头后显示。',
grokRetryAfter: '{time} 后重试',
grokProbe: '探测',
grokProbeTooltip: '发送最小 xAI Responses 探测请求并读取配额响应头',
grokResetUnsupported: '不支持重置',
grokResetUnsupportedTooltip: 'xAI 未向 Grok OAuth 账号开放重置额度接口',
grokNoHeaders: '未观察到配额响应头',
grokLastStatus: '状态 {status}',
grokLastProbe: '探测 {time}',
grokLastHeadersSeen: '响应头 {time}',
passiveSampled: '被动采样',
activeQuery: '查询'
},
@@ -3649,6 +3663,10 @@ export default {
testModeCompact: 'Compact 探测',
modelRestrictionDisabledByPassthrough: '已开启自动透传:模型白名单/映射不会生效。',
},
grok: {
baseUrlHint: 'Grok OAuth 账号会转发到官方 xAI API Base URL。',
apiKeyHint: 'Grok 订阅支持使用 OAuth refresh token;API Key 账号不在本次范围内。'
},
anthropic: {
apiKeyPassthrough: '自动透传(仅替换认证)',
apiKeyPassthroughDesc:
@@ -3976,6 +3994,31 @@ export default {
pleaseEnterRefreshToken: '请输入 Refresh Token',
pleaseEnterSessionToken: '请输入 Session Token'
},
grok: {
title: 'Grok 账号授权',
followSteps: '请按照以下步骤授权您的 xAI/Grok 账号:',
step1GenerateUrl: '生成 xAI 授权链接',
generateAuthUrl: '生成授权链接',
step2OpenUrl: '在浏览器中打开链接并完成授权',
openUrlDesc: '在新标签页中打开授权链接,登录 xAI 并授权 API 访问。',
importantNotice: '当浏览器跳转到本地 callback URL 后,请复制完整 URL 或 code 参数回填到这里。',
step3EnterCode: '输入授权链接或 Code',
authCodeDesc: '授权完成后,粘贴 callback URL、查询字符串或授权码:',
authCode: '授权链接或 Code',
authCodePlaceholder: '粘贴完整 callback URL、?code=... 查询字符串或 code 值',
authCodeHint: '支持完整 callback URL、查询字符串或裸 code。',
refreshTokenAuth: '手动输入 RT',
refreshTokenDesc: '输入已有的 xAI refresh token,支持批量输入(每行一个)。',
refreshTokenPlaceholder: '粘贴您的 xAI refresh token...\n支持多个,每行一个',
validating: '验证中...',
validateAndCreate: '验证并创建账号',
pleaseEnterRefreshToken: '请输入 Refresh Token',
failedToGenerateUrl: '生成 Grok 授权链接失败',
missingExchangeParams: '缺少授权码、state 或 OAuth 会话',
failedToExchangeCode: 'Grok 授权码兑换失败',
failedToValidateRT: '验证 Grok refresh token 失败',
oauthOnlyHint: '首版 Grok 支持仅包含 OAuth 订阅的 Responses API 文本/推理转发。'
},
// Gemini specific
gemini: {
title: 'Gemini 账户授权',
@@ -4192,6 +4235,7 @@ export default {
openaiAccount: 'OpenAI 账号',
geminiAccount: 'Gemini 账号',
antigravityAccount: 'Antigravity 账号',
grokAccount: 'Grok 账号',
inputMethod: '输入方式',
reAuthorizedSuccess: '账号重新授权成功',
// Test Modal
+18 -2
View File
@@ -487,7 +487,7 @@ export interface PaginationConfig {
// ==================== API Key & Group Types ====================
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
export type GroupPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok'
export type SubscriptionType = 'standard' | 'subscription'
@@ -690,7 +690,7 @@ export interface UpdateGroupRequest {
// ==================== Account & Proxy Types ====================
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity'
export type AccountPlatform = 'anthropic' | 'openai' | 'gemini' | 'antigravity' | 'grok'
export type AccountType = 'oauth' | 'setup-token' | 'apikey' | 'upstream' | 'bedrock' | 'service_account'
export type OAuthAddMethod = 'oauth' | 'setup-token'
export type ProxyProtocol = 'http' | 'https' | 'socks5' | 'socks5h'
@@ -944,6 +944,13 @@ export interface AntigravityModelQuota {
reset_time: string // 重置时间 ISO8601
}
export interface GrokQuotaWindow {
limit?: number
remaining?: number
reset_unix?: number
reset_at?: string
}
export interface AccountUsageInfo {
source?: 'passive' | 'active'
updated_at: string | null
@@ -957,6 +964,15 @@ export interface AccountUsageInfo {
gemini_pro_minute?: UsageProgress | null
gemini_flash_minute?: UsageProgress | null
antigravity_quota?: Record<string, AntigravityModelQuota> | null
grok_request_quota?: GrokQuotaWindow | null
grok_token_quota?: GrokQuotaWindow | null
grok_retry_after_seconds?: number | null
grok_entitlement_status?: string
grok_quota_snapshot_state?: string
grok_last_quota_probe_at?: string
grok_last_headers_seen_at?: string
grok_last_status_code?: number
grok_local_usage?: WindowStats | null
ai_credits?: Array<{
credit_type?: string
amount?: number
+3
View File
@@ -29,6 +29,9 @@ export function isImageUsage(row: Pick<ImageBillingRow, 'image_count' | 'billing
}
export function getDisplayBillingMode(row: Pick<ImageBillingRow, 'billing_mode' | 'image_count'> | null | undefined): string | null | undefined {
if ((row?.image_count ?? 0) > 0 && !row?.billing_mode) {
return BILLING_MODE_IMAGE
}
return row?.billing_mode
}
+14 -2
View File
@@ -5,7 +5,7 @@
* instead of defining their own color mappings.
*/
export type Platform = 'anthropic' | 'openai' | 'antigravity' | 'gemini'
export type Platform = 'anthropic' | 'openai' | 'antigravity' | 'gemini' | 'grok'
// ── Badge (bg + text + border, for inline badges with border) ───────
const BADGE: Record<Platform, string> = {
@@ -13,6 +13,7 @@ const BADGE: Record<Platform, string> = {
openai: 'bg-green-500/10 text-green-600 border-green-500/30 dark:text-green-400',
antigravity: 'bg-purple-500/10 text-purple-600 border-purple-500/30 dark:text-purple-400',
gemini: 'bg-blue-500/10 text-blue-600 border-blue-500/30 dark:text-blue-400',
grok: 'bg-slate-500/10 text-slate-600 border-slate-500/30 dark:text-slate-300',
}
const BADGE_DEFAULT = 'bg-slate-500/10 text-slate-600 border-slate-500/30 dark:text-slate-400'
@@ -22,6 +23,7 @@ const BADGE_LIGHT: Record<Platform, string> = {
openai: 'bg-green-500/10 text-green-600 dark:bg-green-500/10 dark:text-green-300',
antigravity: 'bg-purple-500/10 text-purple-600 dark:bg-purple-500/10 dark:text-purple-300',
gemini: 'bg-blue-500/10 text-blue-600 dark:bg-blue-500/10 dark:text-blue-300',
grok: 'bg-slate-500/10 text-slate-600 dark:bg-slate-500/10 dark:text-slate-300',
}
// ── Border ──────────────────────────────────────────────────────────
@@ -30,6 +32,7 @@ const BORDER: Record<Platform, string> = {
openai: 'border-green-500/20 dark:border-green-500/20',
antigravity: 'border-purple-500/20 dark:border-purple-500/20',
gemini: 'border-blue-500/20 dark:border-blue-500/20',
grok: 'border-slate-500/20 dark:border-slate-500/20',
}
const BORDER_DEFAULT = 'border-gray-200 dark:border-dark-700'
@@ -39,6 +42,7 @@ const ACCENT_BAR: Record<Platform, string> = {
openai: 'bg-gradient-to-r from-emerald-400 to-emerald-500',
antigravity: 'bg-gradient-to-r from-purple-400 to-purple-500',
gemini: 'bg-gradient-to-r from-blue-400 to-blue-500',
grok: 'bg-gradient-to-r from-slate-500 to-cyan-500',
}
const ACCENT_BAR_DEFAULT = 'bg-gradient-to-r from-primary-400 to-primary-500'
@@ -48,6 +52,7 @@ const TEXT: Record<Platform, string> = {
openai: 'text-emerald-600 dark:text-emerald-400',
antigravity: 'text-purple-600 dark:text-purple-400',
gemini: 'text-blue-600 dark:text-blue-400',
grok: 'text-slate-700 dark:text-slate-300',
}
const TEXT_DEFAULT = 'text-primary-600 dark:text-primary-400'
@@ -57,6 +62,7 @@ const ICON: Record<Platform, string> = {
openai: 'text-emerald-500 dark:text-emerald-400',
antigravity: 'text-purple-500 dark:text-purple-400',
gemini: 'text-blue-500 dark:text-blue-400',
grok: 'text-slate-500 dark:text-slate-300',
}
const ICON_DEFAULT = 'text-primary-500 dark:text-primary-400'
@@ -66,6 +72,7 @@ const BUTTON: Record<Platform, string> = {
openai: 'bg-green-600 text-white hover:bg-green-700 active:bg-green-800 dark:bg-green-600/80 dark:hover:bg-green-600',
antigravity: 'bg-purple-500 text-white hover:bg-purple-600 active:bg-purple-700 dark:bg-purple-500/80 dark:hover:bg-purple-500',
gemini: 'bg-blue-500 text-white hover:bg-blue-600 active:bg-blue-700 dark:bg-blue-500/80 dark:hover:bg-blue-500',
grok: 'bg-slate-700 text-white hover:bg-slate-800 active:bg-slate-900 dark:bg-slate-600 dark:hover:bg-slate-500',
}
const BUTTON_DEFAULT = 'bg-primary-500 text-white hover:bg-primary-600 dark:bg-primary-600 dark:hover:bg-primary-500'
@@ -75,6 +82,7 @@ const DISCOUNT: Record<Platform, string> = {
openai: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
antigravity: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
gemini: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
grok: 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
}
const DISCOUNT_DEFAULT = 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
@@ -84,6 +92,7 @@ const GRADIENT: Record<Platform, string> = {
openai: 'from-emerald-500 to-emerald-600',
antigravity: 'from-purple-500 to-purple-600',
gemini: 'from-blue-500 to-blue-600',
grok: 'from-slate-600 to-cyan-600',
}
const GRADIENT_DEFAULT = 'from-primary-500 to-primary-600'
@@ -93,6 +102,7 @@ const GRADIENT_TEXT: Record<Platform, string> = {
openai: 'text-emerald-100',
antigravity: 'text-purple-100',
gemini: 'text-blue-100',
grok: 'text-slate-100',
}
const GRADIENT_TEXT_DEFAULT = 'text-primary-100'
@@ -101,13 +111,14 @@ const GRADIENT_SUBTEXT: Record<Platform, string> = {
openai: 'text-emerald-200',
antigravity: 'text-purple-200',
gemini: 'text-blue-200',
grok: 'text-slate-200',
}
const GRADIENT_SUBTEXT_DEFAULT = 'text-primary-200'
// ── Public API ──────────────────────────────────────────────────────
function isPlatform(p: string): p is Platform {
return p === 'anthropic' || p === 'openai' || p === 'antigravity' || p === 'gemini'
return p === 'anthropic' || p === 'openai' || p === 'antigravity' || p === 'gemini' || p === 'grok'
}
export function platformBadgeClass(p: string): string {
@@ -160,6 +171,7 @@ export function platformLabel(p: string): string {
case 'openai': return 'OpenAI'
case 'antigravity': return 'Antigravity'
case 'gemini': return 'Gemini'
case 'grok': return 'Grok'
default: return p || 'API'
}
}
+1 -1
View File
@@ -760,7 +760,7 @@ const form = reactive({
let abortController: AbortController | null = null
// ── Platform config ──
const platformOrder: GroupPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity']
const platformOrder: GroupPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity', 'grok']
// ── Helpers ──
function formatDate(value: string): string {
+16 -10
View File
@@ -533,19 +533,25 @@ const formatTokens = (value: number | undefined): string => {
return value.toLocaleString()
}
const formatNumber = (value: number): string => {
return value.toLocaleString()
const toFiniteNumber = (value: unknown): number => {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : 0
}
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 formatNumber = (value: number | null | undefined): string => {
return toFiniteNumber(value).toLocaleString()
}
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)
}
const formatDuration = (ms: number): string => {
+2
View File
@@ -3138,6 +3138,7 @@ const platformOptions = computed(() => [
{ value: "openai", label: "OpenAI" },
{ value: "gemini", label: "Gemini" },
{ value: "antigravity", label: "Antigravity" },
{ value: "grok", label: "Grok" },
]);
const platformFilterOptions = computed(() => [
@@ -3146,6 +3147,7 @@ const platformFilterOptions = computed(() => [
{ value: "openai", label: "OpenAI" },
{ value: "gemini", label: "Gemini" },
{ value: "antigravity", label: "Antigravity" },
{ value: "grok", label: "Grok" },
]);
const editStatusOptions = computed(() => [
+2 -2
View File
@@ -3287,7 +3287,7 @@
</tr>
</thead>
<tbody class="space-y-2">
<tr v-for="p in (['anthropic', 'openai', 'gemini', 'antigravity'] as const)" :key="p" class="align-top">
<tr v-for="p in (['anthropic', 'openai', 'gemini', 'antigravity', 'grok'] as const)" :key="p" class="align-top">
<td class="pr-4 py-1">
<span class="font-mono text-xs text-gray-700 dark:text-gray-300">{{ p }}</span>
</td>
@@ -3622,7 +3622,7 @@
</tr>
</thead>
<tbody>
<tr v-for="p in (['anthropic', 'openai', 'gemini', 'antigravity'] as const)" :key="`${authSource.source}-pq-${p}`" class="align-top">
<tr v-for="p in (['anthropic', 'openai', 'gemini', 'antigravity', 'grok'] as const)" :key="`${authSource.source}-pq-${p}`" class="align-top">
<td class="pr-4 py-1">
<span class="font-mono text-xs text-gray-700 dark:text-gray-300">{{ p }}</span>
</td>
@@ -1149,7 +1149,7 @@ describe("admin SettingsView platform quota matrix", () => {
getProviders.mockResolvedValue({ data: [] });
});
it("从 baseSettings 加载默认平台配额数据并在 Users tab 渲染 4 平台行", async () => {
it("从 baseSettings 加载默认平台配额数据并在 Users tab 渲染 5 平台行", async () => {
const wrapper = mountView();
await flushPromises();
await openUsersTab(wrapper);
@@ -1164,7 +1164,7 @@ describe("admin SettingsView platform quota matrix", () => {
expect(html).toContain("antigravity");
});
it("保存时 updateSettings payload 应包含嵌套 default_platform_quotas 对象(含全 4 平台)", async () => {
it("保存时 updateSettings payload 应包含嵌套 default_platform_quotas 对象(含全 5 平台)", async () => {
const wrapper = mountView();
await flushPromises();
await openUsersTab(wrapper);
@@ -1180,7 +1180,7 @@ describe("admin SettingsView platform quota matrix", () => {
// 应携带嵌套对象,而非扁平字段
expect(payload).toHaveProperty("default_platform_quotas");
const quotas = payload["default_platform_quotas"] as Record<string, unknown>;
const platforms = ["anthropic", "openai", "gemini", "antigravity"];
const platforms = ["anthropic", "openai", "gemini", "antigravity", "grok"];
for (const p of platforms) {
expect(quotas).toHaveProperty(p);
const pq = quotas[p] as Record<string, unknown>;
@@ -1194,7 +1194,7 @@ describe("admin SettingsView platform quota matrix", () => {
expect(payload).not.toHaveProperty("default_platform_quota_openai_weekly");
});
it("加载后 form.default_platform_quotas 含全 4 平台,从嵌套 JSON 正确读取数值", async () => {
it("加载后 form.default_platform_quotas 含全 5 平台,从嵌套 JSON 正确读取数值", async () => {
getSettings.mockResolvedValueOnce({
...baseSettingsResponse,
default_platform_quotas: {
@@ -111,7 +111,8 @@ const platformOptions = computed(() => [
{ value: 'openai', label: 'OpenAI' },
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'antigravity', label: 'Antigravity' }
{ value: 'antigravity', label: 'Antigravity' },
{ value: 'grok', label: 'Grok' }
])
const timeRangeOptions = computed(() => [
+17 -9
View File
@@ -500,17 +500,25 @@ async function handleVerify(): Promise<void> {
}
if (isPendingOAuthFlow()) {
const payload: Record<string, unknown> = {
email: email.value,
password: password.value,
verify_code: verifyCode.value.trim(),
...oauthAffiliatePayload(affCode.value || loadAffiliateReferralCode()),
}
if (invitationCode.value) {
payload.invitation_code = invitationCode.value
}
if (pendingAdoptionDecision.value?.adoptDisplayName !== undefined) {
payload.adopt_display_name = pendingAdoptionDecision.value.adoptDisplayName
}
if (pendingAdoptionDecision.value?.adoptAvatar !== undefined) {
payload.adopt_avatar = pendingAdoptionDecision.value.adoptAvatar
}
const { data } = await apiClient.post<PendingOAuthCreateAccountResponse>(
'/auth/oauth/pending/create-account',
{
email: email.value,
password: password.value,
verify_code: verifyCode.value.trim(),
invitation_code: invitationCode.value || undefined,
...oauthAffiliatePayload(affCode.value || loadAffiliateReferralCode()),
adopt_display_name: pendingAdoptionDecision.value?.adoptDisplayName,
adopt_avatar: pendingAdoptionDecision.value?.adoptAvatar
}
payload
)
if (isPendingOAuthSessionResponse(data)) {
sessionStorage.removeItem('register_data')
@@ -137,7 +137,6 @@ describe('EmailVerifyView', () => {
JSON.stringify({
email: 'fresh@example.com',
password: 'secret-123',
aff_code: 'AFF123',
})
)
@@ -305,6 +304,7 @@ describe('EmailVerifyView', () => {
JSON.stringify({
email: 'fresh@example.com',
password: 'secret-123',
aff_code: 'AFF123',
})
)
apiClientPostMock.mockResolvedValue({
+1 -1
View File
@@ -513,7 +513,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>