Merge pull request #3867 from heathermhuang/codex/postmerge-grok-issues-3848-3861

fix: Grok OAuth, image, usage, and quota handling
This commit is contained in:
Wesley Liddick
2026-07-09 19:48:32 +08:00
committed by GitHub
17 changed files with 351 additions and 15 deletions
+1
View File
@@ -63,6 +63,7 @@ export interface GrokQuotaSnapshot {
export interface GrokQuotaProbeResult {
source: 'active_probe'
model: string
snapshot?: GrokQuotaSnapshot | null
status_code?: number
headers_observed: boolean
@@ -372,6 +372,13 @@
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800" :title="t('usage.accountBilled')">
A ${{ formatWindowCost(grokLocalUsage) }}
</span>
<span
v-if="grokLocalUsage.user_cost != null"
class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"
:title="t('usage.userBilled')"
>
U ${{ formatWindowUserCost(grokLocalUsage) }}
</span>
</div>
</div>
<UsageProgressBar
@@ -1031,7 +1038,7 @@ const makeGrokQuotaBar = (quota?: { limit?: number | null; remaining?: number |
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),
utilization: (used / quota.limit) * 100,
resetsAt: quota.reset_at || null
}
}
@@ -1087,6 +1094,7 @@ const grokRetryAfterLabel = computed(() => {
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 formatWindowUserCost = (stats: WindowStats) => (stats.user_cost ?? 0).toFixed(2)
// 账户类型显示标签
const antigravityTierLabel = computed(() => {
@@ -566,6 +566,58 @@ describe('AccountUsageCell', () => {
expect(badges.some(node => node.attributes('title') === 'usage.userBilled')).toBe(true)
})
it('Grok OAuth 会展示本地 user billed 用量并保留超限百分比', async () => {
getUsage.mockResolvedValue({
grok_local_usage: {
requests: 4,
tokens: 1200,
cost: 0.12,
standard_cost: 0.12,
user_cost: 0.34
},
grok_request_quota: {
limit: 10,
remaining: -2,
reset_at: '2026-07-09T16:00:00Z'
},
grok_quota_snapshot_state: 'observed'
})
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 3861,
platform: 'grok',
type: 'oauth',
extra: {}
})
},
global: {
stubs: {
UsageProgressBar: {
props: ['label', 'utilization', 'resetsAt', 'color'],
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ resetsAt }}</div>'
},
AccountQuotaInfo: true,
GrokQuotaProbeCell: true
}
}
})
await flushPromises()
expect(getUsage).toHaveBeenCalledWith(3861)
expect(wrapper.text()).toContain('4 req')
expect(wrapper.text()).toContain('1.2K')
expect(wrapper.text()).toContain('A $0.12')
expect(wrapper.text()).toContain('U $0.34')
expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokRequests|120|2026-07-09T16:00:00Z')
const badges = wrapper.findAll('span[title]')
expect(badges.some(node => node.attributes('title') === 'usage.accountBilled')).toBe(true)
expect(badges.some(node => node.attributes('title') === 'usage.userBilled')).toBe(true)
})
it('Key 账号在 today stats loading 时显示骨架屏', async () => {
const wrapper = mount(AccountUsageCell, {
props: {
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError: vi.fn()
})
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => {
const messages: Record<string, string> = {
'admin.accounts.oauth.grok.failedToExchangeCode': 'Grok 授权码兑换失败',
'admin.accounts.oauth.grok.errors.GROK_OAUTH_INVALID_STATE':
'Grok OAuth state 与当前会话不匹配。请粘贴同一次生成的授权链接返回的回调 URL。'
}
return messages[key] ?? key
}
})
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
grok: {
generateAuthUrl: vi.fn(),
exchangeCode: vi.fn(),
refreshGrokToken: vi.fn()
}
}
}))
import { useGrokOAuth } from '@/composables/useGrokOAuth'
import { adminAPI } from '@/api/admin'
describe('useGrokOAuth.exchangeAuthCode', () => {
it('shows a state mismatch recovery hint from structured backend errors', async () => {
vi.mocked(adminAPI.grok.exchangeCode).mockRejectedValueOnce({
status: 400,
reason: 'GROK_OAUTH_INVALID_STATE',
message: 'invalid oauth state'
})
const oauth = useGrokOAuth()
const tokenInfo = await oauth.exchangeAuthCode({
code: 'code',
sessionId: 'session-id',
state: 'wrong-state'
})
expect(tokenInfo).toBeNull()
expect(oauth.error.value).toBe(
'Grok OAuth state 与当前会话不匹配。请粘贴同一次生成的授权链接返回的回调 URL。'
)
})
})
@@ -69,6 +69,14 @@ describe('useModelWhitelist', () => {
})
})
it('grok 模型列表包含 Composer 默认项和兼容别名', () => {
const models = getModelsByPlatform('grok')
expect(models).toContain('grok-composer-2.5-fast')
expect(models).toContain('grok-composer')
expect(models).toContain('composer-2.5')
})
it('gemini 模型列表包含原生生图模型', () => {
const models = getModelsByPlatform('gemini')
+14 -3
View File
@@ -3,6 +3,7 @@ import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { GrokTokenInfo } from '@/api/admin/grok'
import { extractApiErrorMessage, extractI18nErrorMessage } from '@/utils/apiError'
export function useGrokOAuth() {
const appStore = useAppStore()
@@ -39,7 +40,7 @@ export function useGrokOAuth() {
state.value = response.state
return true
} catch (err: any) {
error.value = err.response?.data?.detail || t('admin.accounts.oauth.grok.failedToGenerateUrl')
error.value = extractApiErrorMessage(err, t('admin.accounts.oauth.grok.failedToGenerateUrl'))
appStore.showError(error.value)
return false
} finally {
@@ -72,7 +73,12 @@ export function useGrokOAuth() {
return await adminAPI.grok.exchangeCode(payload as any)
} catch (err: any) {
error.value = err.response?.data?.detail || t('admin.accounts.oauth.grok.failedToExchangeCode')
error.value = extractI18nErrorMessage(
err,
t,
'admin.accounts.oauth.grok.errors',
t('admin.accounts.oauth.grok.failedToExchangeCode')
)
appStore.showError(error.value)
return null
} finally {
@@ -95,7 +101,12 @@ export function useGrokOAuth() {
try {
return await adminAPI.grok.refreshGrokToken(refreshToken.trim(), proxyId)
} catch (err: any) {
error.value = err.response?.data?.detail || t('admin.accounts.oauth.grok.failedToValidateRT')
error.value = extractI18nErrorMessage(
err,
t,
'admin.accounts.oauth.grok.errors',
t('admin.accounts.oauth.grok.failedToValidateRT')
)
return null
} finally {
loading.value = false
@@ -148,6 +148,7 @@ const xaiModels = [
'grok-build',
'grok-build-latest',
'grok-composer',
'composer-2.5',
'grok-4.20-reasoning',
'grok-4.20-non-reasoning',
'grok-imagine',
@@ -306,6 +307,7 @@ const grokPresetMappings = [
{ 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: 'Build Latest', from: 'grok-build-latest', to: 'grok-4.5', color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400' },
{ label: 'Composer 2.5', from: 'grok-composer', to: 'grok-composer-2.5-fast', color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400' },
{ label: 'Composer legacy', from: 'composer-2.5', to: 'grok-composer-2.5-fast', color: 'bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-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' },
{ label: 'Imagine Image', from: 'grok-imagine', to: 'grok-imagine-image-quality', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
@@ -873,6 +873,22 @@ export default {
missingExchangeParams: 'Missing authorization code, state, or OAuth session',
failedToExchangeCode: 'Failed to exchange Grok authorization code',
failedToValidateRT: 'Failed to validate Grok refresh token',
errors: {
GROK_OAUTH_SESSION_NOT_FOUND:
'Grok OAuth session was not found or has expired. Generate a new auth URL and paste the newest callback URL.',
GROK_OAUTH_INVALID_STATE:
'Grok OAuth state does not match this session. Paste the callback URL from the same generated auth link.',
GROK_OAUTH_STATE_REQUIRED:
'The callback URL is missing the OAuth state. Paste the full callback URL, not only the code.',
GROK_OAUTH_CODE_REQUIRED:
'The Grok authorization code is missing. Paste the full callback URL, query string, or code value.',
GROK_OAUTH_NO_REFRESH_TOKEN:
'The Grok response did not include a refresh token. Generate a new auth URL and approve offline access again.',
GROK_OAUTH_PROXY_NOT_AVAILABLE:
'Grok OAuth proxy lookup is unavailable. Check the selected proxy and retry.',
GROK_OAUTH_PROXY_NOT_FOUND:
'The selected proxy could not be found. Choose an available proxy and retry.'
},
oauthOnlyHint: 'Initial Grok support is OAuth subscription-backed Responses API text and reasoning traffic only.'
},
// Gemini specific
@@ -960,6 +960,22 @@ export default {
missingExchangeParams: '缺少授权码、state 或 OAuth 会话',
failedToExchangeCode: 'Grok 授权码兑换失败',
failedToValidateRT: '验证 Grok refresh token 失败',
errors: {
GROK_OAUTH_SESSION_NOT_FOUND:
'Grok OAuth 会话不存在或已过期。请重新生成授权链接,并粘贴最新的回调链接。',
GROK_OAUTH_INVALID_STATE:
'Grok OAuth state 与当前会话不匹配。请粘贴同一次生成的授权链接返回的回调 URL。',
GROK_OAUTH_STATE_REQUIRED:
'回调链接缺少 OAuth state。请粘贴完整 callback URL,不要只粘贴 code。',
GROK_OAUTH_CODE_REQUIRED:
'缺少 Grok 授权码。请粘贴完整 callback URL、查询字符串或 code 值。',
GROK_OAUTH_NO_REFRESH_TOKEN:
'Grok 响应未返回 refresh token。请重新生成授权链接,并再次确认 offline access 授权。',
GROK_OAUTH_PROXY_NOT_AVAILABLE:
'无法查询 Grok OAuth 代理配置。请检查选择的代理后重试。',
GROK_OAUTH_PROXY_NOT_FOUND:
'找不到所选代理。请选择可用代理后重试。'
},
oauthOnlyHint: '首版 Grok 支持仅包含 OAuth 订阅的 Responses API 文本/推理转发。'
},
// Gemini specific