mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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:
@@ -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')
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user