mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #4009 from heathermhuang/codex/fix-recent-grok-issues
fix: expand Grok API, CLI, billing, and setup support
This commit is contained in:
@@ -386,6 +386,7 @@
|
||||
:label="t('admin.accounts.usageWindow.grokRequests')"
|
||||
:utilization="grokRequestQuotaBar.utilization"
|
||||
:resets-at="grokRequestQuotaBar.resetsAt"
|
||||
:remaining-capacity="true"
|
||||
color="indigo"
|
||||
/>
|
||||
<UsageProgressBar
|
||||
@@ -393,6 +394,7 @@
|
||||
:label="t('admin.accounts.usageWindow.grokTokens')"
|
||||
:utilization="grokTokenQuotaBar.utilization"
|
||||
:resets-at="grokTokenQuotaBar.resetsAt"
|
||||
:remaining-capacity="true"
|
||||
color="emerald"
|
||||
/>
|
||||
<div v-if="grokRetryAfterLabel" class="text-[10px] text-amber-600 dark:text-amber-400">
|
||||
@@ -1036,9 +1038,9 @@ interface GrokQuotaBarInfo {
|
||||
|
||||
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)
|
||||
const remaining = Math.min(quota.limit, Math.max(0, quota.remaining))
|
||||
return {
|
||||
utilization: (used / quota.limit) * 100,
|
||||
utilization: (remaining / quota.limit) * 100,
|
||||
resetsAt: quota.reset_at || null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,7 +352,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Type Selection (Grok - OAuth only) -->
|
||||
<!-- Account Type Selection (Grok) -->
|
||||
<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">
|
||||
@@ -381,10 +381,34 @@
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.grokOauth') }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="grok-account-type-api-key"
|
||||
@click="accountCategory = 'apikey'"
|
||||
:class="[
|
||||
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
|
||||
accountCategory === 'apikey'
|
||||
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
|
||||
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||
accountCategory === 'apikey'
|
||||
? 'bg-purple-500 text-white'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
|
||||
]"
|
||||
>
|
||||
<Icon name="key" size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">API Key</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.types.responsesApi') }}</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) -->
|
||||
@@ -1087,10 +1111,12 @@
|
||||
? 'https://api.openai.com'
|
||||
: form.platform === 'gemini'
|
||||
? 'https://generativelanguage.googleapis.com'
|
||||
: 'https://api.anthropic.com'
|
||||
: form.platform === 'grok'
|
||||
? 'https://api.x.ai/v1'
|
||||
: 'https://api.anthropic.com'
|
||||
"
|
||||
/>
|
||||
<p class="input-hint">{{ baseUrlHint }}</p>
|
||||
<p v-if="baseUrlHint" class="input-hint">{{ baseUrlHint }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.apiKeyRequired') }}</label>
|
||||
@@ -1104,10 +1130,12 @@
|
||||
? 'sk-proj-...'
|
||||
: form.platform === 'gemini'
|
||||
? 'AIza...'
|
||||
: 'sk-ant-...'
|
||||
: form.platform === 'grok'
|
||||
? 'xai-...'
|
||||
: 'sk-ant-...'
|
||||
"
|
||||
/>
|
||||
<p class="input-hint">{{ apiKeyHint }}</p>
|
||||
<p v-if="apiKeyHint" class="input-hint">{{ apiKeyHint }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Gemini API Key tier selection -->
|
||||
@@ -3483,14 +3511,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')
|
||||
if (form.platform === 'grok') return ''
|
||||
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')
|
||||
if (form.platform === 'grok') return ''
|
||||
return t('admin.accounts.apiKeyHint')
|
||||
})
|
||||
|
||||
@@ -4887,7 +4915,9 @@ const handleSubmit = async () => {
|
||||
? 'https://api.openai.com'
|
||||
: form.platform === 'gemini'
|
||||
? 'https://generativelanguage.googleapis.com'
|
||||
: 'https://api.anthropic.com'
|
||||
: form.platform === 'grok'
|
||||
? 'https://api.x.ai/v1'
|
||||
: 'https://api.anthropic.com'
|
||||
|
||||
// Build credentials with optional model mapping
|
||||
const credentials: Record<string, unknown> = {
|
||||
|
||||
@@ -41,10 +41,12 @@
|
||||
? 'https://generativelanguage.googleapis.com'
|
||||
: account.platform === 'antigravity'
|
||||
? 'https://cloudcode-pa.googleapis.com'
|
||||
: 'https://api.anthropic.com'
|
||||
: account.platform === 'grok'
|
||||
? 'https://api.x.ai/v1'
|
||||
: 'https://api.anthropic.com'
|
||||
"
|
||||
/>
|
||||
<p class="input-hint">{{ baseUrlHint }}</p>
|
||||
<p v-if="baseUrlHint" class="input-hint">{{ baseUrlHint }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.apiKey') }}</label>
|
||||
@@ -63,7 +65,9 @@
|
||||
? 'AIza...'
|
||||
: account.platform === 'antigravity'
|
||||
? 'sk-...'
|
||||
: 'sk-ant-...'
|
||||
: account.platform === 'grok'
|
||||
? 'xai-...'
|
||||
: 'sk-ant-...'
|
||||
"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.accounts.leaveEmptyToKeep') }}</p>
|
||||
@@ -2594,6 +2598,7 @@ const baseUrlHint = computed(() => {
|
||||
if (!props.account) return t('admin.accounts.baseUrlHint')
|
||||
if (props.account.platform === 'openai') return t('admin.accounts.openai.baseUrlHint')
|
||||
if (props.account.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint')
|
||||
if (props.account.platform === 'grok') return ''
|
||||
return t('admin.accounts.baseUrlHint')
|
||||
})
|
||||
|
||||
@@ -3043,6 +3048,7 @@ const tempUnschedPresets = computed(() => [
|
||||
const defaultBaseUrl = computed(() => {
|
||||
if (props.account?.platform === 'openai') return 'https://api.openai.com'
|
||||
if (props.account?.platform === 'gemini') return 'https://generativelanguage.googleapis.com'
|
||||
if (props.account?.platform === 'grok') return 'https://api.x.ai/v1'
|
||||
return 'https://api.anthropic.com'
|
||||
})
|
||||
|
||||
@@ -3335,7 +3341,9 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
? 'https://api.openai.com'
|
||||
: newAccount.platform === 'gemini'
|
||||
? 'https://generativelanguage.googleapis.com'
|
||||
: 'https://api.anthropic.com'
|
||||
: newAccount.platform === 'grok'
|
||||
? 'https://api.x.ai/v1'
|
||||
: 'https://api.anthropic.com'
|
||||
editBaseUrl.value = (credentials.base_url as string) || platformDefaultUrl
|
||||
|
||||
// Load model mappings and detect mode
|
||||
@@ -3411,7 +3419,9 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
? 'https://api.openai.com'
|
||||
: newAccount.platform === 'gemini'
|
||||
? 'https://generativelanguage.googleapis.com'
|
||||
: 'https://api.anthropic.com'
|
||||
: newAccount.platform === 'grok'
|
||||
? 'https://api.x.ai/v1'
|
||||
: 'https://api.anthropic.com'
|
||||
editBaseUrl.value = platformDefaultUrl
|
||||
|
||||
// Load model mappings for OpenAI/Grok OAuth accounts
|
||||
|
||||
@@ -69,6 +69,7 @@ const props = defineProps<{
|
||||
color: 'indigo' | 'emerald' | 'purple' | 'amber'
|
||||
windowStats?: WindowStats | null
|
||||
showNowWhenIdle?: boolean
|
||||
remainingCapacity?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -109,6 +110,14 @@ const labelClass = computed(() => {
|
||||
|
||||
// Progress bar color based on utilization
|
||||
const barClass = computed(() => {
|
||||
if (props.remainingCapacity) {
|
||||
if (props.utilization <= 20) {
|
||||
return 'bg-red-500'
|
||||
} else if (props.utilization <= 50) {
|
||||
return 'bg-amber-500'
|
||||
}
|
||||
return 'bg-green-500'
|
||||
}
|
||||
if (props.utilization >= 100) {
|
||||
return 'bg-red-500'
|
||||
} else if (props.utilization >= 80) {
|
||||
@@ -120,6 +129,14 @@ const barClass = computed(() => {
|
||||
|
||||
// Text color based on utilization
|
||||
const textClass = computed(() => {
|
||||
if (props.remainingCapacity) {
|
||||
if (props.utilization <= 20) {
|
||||
return 'text-red-600 dark:text-red-400'
|
||||
} else if (props.utilization <= 50) {
|
||||
return 'text-amber-600 dark:text-amber-400'
|
||||
}
|
||||
return 'text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
if (props.utilization >= 100) {
|
||||
return 'text-red-600 dark:text-red-400'
|
||||
} else if (props.utilization >= 80) {
|
||||
@@ -131,12 +148,16 @@ const textClass = computed(() => {
|
||||
|
||||
// Bar width (capped at 100%)
|
||||
const barWidth = computed(() => {
|
||||
return `${Math.min(props.utilization, 100)}%`
|
||||
return `${Math.min(Math.max(props.utilization, 0), 100)}%`
|
||||
})
|
||||
|
||||
// Display percentage (cap at 999% for readability)
|
||||
const displayPercent = computed(() => {
|
||||
const percent = Math.round(props.utilization)
|
||||
const percent = Math.round(
|
||||
props.remainingCapacity
|
||||
? Math.min(Math.max(props.utilization, 0), 100)
|
||||
: props.utilization
|
||||
)
|
||||
return percent > 999 ? '>999%' : `${percent}%`
|
||||
})
|
||||
|
||||
|
||||
@@ -566,7 +566,7 @@ describe('AccountUsageCell', () => {
|
||||
expect(badges.some(node => node.attributes('title') === 'usage.userBilled')).toBe(true)
|
||||
})
|
||||
|
||||
it('Grok OAuth 会展示本地 user billed 用量并保留超限百分比', async () => {
|
||||
it('Grok OAuth 会展示本地 user billed 用量并把耗尽配额显示为 0% 剩余', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
grok_local_usage: {
|
||||
requests: 4,
|
||||
@@ -611,13 +611,55 @@ describe('AccountUsageCell', () => {
|
||||
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')
|
||||
expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokRequests|0|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('Grok OAuth 配额条按剩余容量显示 100% 满格和 25% 低量', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
grok_request_quota: {
|
||||
limit: 100,
|
||||
remaining: 100,
|
||||
reset_at: '2026-07-09T16:00:00Z'
|
||||
},
|
||||
grok_token_quota: {
|
||||
limit: 1000,
|
||||
remaining: 250,
|
||||
reset_at: '2026-07-09T16:00:00Z'
|
||||
},
|
||||
grok_quota_snapshot_state: 'observed'
|
||||
})
|
||||
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
account: makeAccount({
|
||||
id: 4073,
|
||||
platform: 'grok',
|
||||
type: 'oauth',
|
||||
extra: {}
|
||||
})
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: {
|
||||
props: ['label', 'utilization', 'resetsAt', 'color', 'remainingCapacity'],
|
||||
template: '<div class="usage-bar">{{ label }}|{{ utilization }}|{{ remainingCapacity }}</div>'
|
||||
},
|
||||
AccountQuotaInfo: true,
|
||||
GrokQuotaProbeCell: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokRequests|100|true')
|
||||
expect(wrapper.text()).toContain('admin.accounts.usageWindow.grokTokens|25|true')
|
||||
})
|
||||
|
||||
it('Key 账号在 today stats loading 时显示骨架屏', async () => {
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(
|
||||
resolve(process.cwd(), 'src/components/account/CreateAccountModal.vue'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
describe('CreateAccountModal Grok account types', () => {
|
||||
it('offers API-key setup alongside OAuth with the official xAI default', () => {
|
||||
expect(source).toContain('data-testid="grok-account-type-api-key"')
|
||||
expect(source).toContain("@click=\"accountCategory = 'apikey'\"")
|
||||
expect(source).toContain("newPlatform === 'grok'")
|
||||
expect(source).toContain("? 'https://api.x.ai/v1'")
|
||||
expect(source).toContain("form.platform === 'grok'")
|
||||
expect(source).toContain("? 'xai-...'")
|
||||
})
|
||||
})
|
||||
@@ -267,6 +267,18 @@ function buildGrokOAuthAccount() {
|
||||
} as any
|
||||
}
|
||||
|
||||
function buildGrokAPIKeyAccount() {
|
||||
return {
|
||||
...buildAccount(),
|
||||
id: 6,
|
||||
name: 'Grok API Key',
|
||||
platform: 'grok',
|
||||
credentials: {},
|
||||
credentials_status: { has_api_key: true },
|
||||
concurrency: 2
|
||||
} as any
|
||||
}
|
||||
|
||||
function buildOpenAISetupTokenAccount() {
|
||||
return {
|
||||
...buildAccount(),
|
||||
@@ -412,6 +424,24 @@ describe('EditAccountModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the official xAI base URL when a Grok API-key account omits base_url', async () => {
|
||||
const account = buildGrokAPIKeyAccount()
|
||||
updateAccountMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
|
||||
updateAccountMock.mockResolvedValue(account)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
|
||||
expect((wrapper.get('input[placeholder="https://api.x.ai/v1"]').element as HTMLInputElement).value)
|
||||
.toBe('https://api.x.ai/v1')
|
||||
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
|
||||
expect(updateAccountMock).toHaveBeenCalledTimes(1)
|
||||
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.base_url).toBe('https://api.x.ai/v1')
|
||||
})
|
||||
|
||||
it('only submits model mapping credentials when saving an OpenAI spark shadow account', async () => {
|
||||
authIsSimpleMode.value = false
|
||||
const account = buildOpenAISparkShadowAccount()
|
||||
|
||||
@@ -96,4 +96,54 @@ describe('UsageProgressBar', () => {
|
||||
expect(wrapper.text()).toContain('usage.resetNow')
|
||||
expect(wrapper.text()).not.toContain('usage.resetPending')
|
||||
})
|
||||
|
||||
it('剩余容量模式在 100% 时显示满格绿色', () => {
|
||||
const wrapper = mount(UsageProgressBar, {
|
||||
props: {
|
||||
label: 'Req',
|
||||
utilization: 100,
|
||||
remainingCapacity: true,
|
||||
color: 'indigo'
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('100%')
|
||||
expect(wrapper.get('.h-1\\.5 > div').attributes('style')).toContain('width: 100%')
|
||||
expect(wrapper.get('.h-1\\.5 > div').classes()).toContain('bg-green-500')
|
||||
})
|
||||
|
||||
it('剩余容量模式在低量和耗尽时缩短并变红', async () => {
|
||||
const wrapper = mount(UsageProgressBar, {
|
||||
props: {
|
||||
label: 'Req',
|
||||
utilization: 15,
|
||||
remainingCapacity: true,
|
||||
color: 'indigo'
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('15%')
|
||||
expect(wrapper.get('.h-1\\.5 > div').attributes('style')).toContain('width: 15%')
|
||||
expect(wrapper.get('.h-1\\.5 > div').classes()).toContain('bg-red-500')
|
||||
|
||||
await wrapper.setProps({ utilization: 0 })
|
||||
|
||||
expect(wrapper.text()).toContain('0%')
|
||||
expect(wrapper.get('.h-1\\.5 > div').attributes('style')).toContain('width: 0%')
|
||||
expect(wrapper.get('.h-1\\.5 > div').classes()).toContain('bg-red-500')
|
||||
})
|
||||
|
||||
it('默认利用率模式仍把超限显示为满格红色', () => {
|
||||
const wrapper = mount(UsageProgressBar, {
|
||||
props: {
|
||||
label: '5h',
|
||||
utilization: 120,
|
||||
color: 'indigo'
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('120%')
|
||||
expect(wrapper.get('.h-1\\.5 > div').attributes('style')).toContain('width: 100%')
|
||||
expect(wrapper.get('.h-1\\.5 > div').classes()).toContain('bg-red-500')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -181,6 +181,8 @@ const defaultClientTab = computed(() => {
|
||||
switch (props.platform) {
|
||||
case 'openai':
|
||||
return 'codex'
|
||||
case 'grok':
|
||||
return 'grok'
|
||||
case 'gemini':
|
||||
return 'gemini'
|
||||
case 'antigravity':
|
||||
@@ -288,6 +290,11 @@ const clientTabs = computed((): TabConfig[] => {
|
||||
{ id: 'gemini', label: t('keys.useKeyModal.cliTabs.geminiCli'), icon: SparkleIcon },
|
||||
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
|
||||
]
|
||||
case 'grok':
|
||||
return [
|
||||
{ id: 'grok', label: t('keys.useKeyModal.cliTabs.grokCli'), icon: TerminalIcon },
|
||||
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
|
||||
]
|
||||
default:
|
||||
return [
|
||||
{ id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon },
|
||||
@@ -313,7 +320,7 @@ const showShellTabs = computed(() => activeClientTab.value !== 'opencode')
|
||||
|
||||
const currentTabs = computed(() => {
|
||||
if (!showShellTabs.value) return []
|
||||
if (activeClientTab.value === 'codex' || activeClientTab.value === 'codex-ws') {
|
||||
if (activeClientTab.value === 'codex' || activeClientTab.value === 'codex-ws' || activeClientTab.value === 'grok') {
|
||||
return openaiTabs
|
||||
}
|
||||
return shellTabs
|
||||
@@ -330,6 +337,8 @@ const platformDescription = computed(() => {
|
||||
return t('keys.useKeyModal.gemini.description')
|
||||
case 'antigravity':
|
||||
return t('keys.useKeyModal.antigravity.description')
|
||||
case 'grok':
|
||||
return t('keys.useKeyModal.grok.description')
|
||||
default:
|
||||
return t('keys.useKeyModal.description')
|
||||
}
|
||||
@@ -350,6 +359,10 @@ const platformNote = computed(() => {
|
||||
return activeClientTab.value === 'claude'
|
||||
? t('keys.useKeyModal.antigravity.claudeNote')
|
||||
: t('keys.useKeyModal.antigravity.geminiNote')
|
||||
case 'grok':
|
||||
return activeTab.value === 'windows'
|
||||
? t('keys.useKeyModal.grok.noteWindows')
|
||||
: t('keys.useKeyModal.grok.note')
|
||||
default:
|
||||
return t('keys.useKeyModal.note')
|
||||
}
|
||||
@@ -407,6 +420,8 @@ const currentFiles = computed((): FileConfig[] => {
|
||||
generateOpenCodeConfig('antigravity-claude', antigravityBase, apiKey, 'opencode.json (Claude)'),
|
||||
generateOpenCodeConfig('antigravity-gemini', antigravityGeminiBase, apiKey, 'opencode.json (Gemini)')
|
||||
]
|
||||
case 'grok':
|
||||
return [generateOpenCodeConfig('grok', apiBase, apiKey)]
|
||||
default:
|
||||
return [generateOpenCodeConfig('openai', apiBase, apiKey)]
|
||||
}
|
||||
@@ -428,6 +443,8 @@ const currentFiles = computed((): FileConfig[] => {
|
||||
return [generateGeminiCliContent(`${baseUrl}/antigravity`, apiKey)]
|
||||
}
|
||||
return generateAnthropicFiles(`${baseUrl}/antigravity`, apiKey)
|
||||
case 'grok':
|
||||
return generateGrokFiles(apiBase, apiKey)
|
||||
default:
|
||||
return generateAnthropicFiles(baseUrl, apiKey)
|
||||
}
|
||||
@@ -568,6 +585,30 @@ goals = true`
|
||||
]
|
||||
}
|
||||
|
||||
function generateGrokFiles(baseUrl: string, apiKey: string): FileConfig[] {
|
||||
const isWindows = activeTab.value === 'windows'
|
||||
const configDir = isWindows ? '%userprofile%\\.grok' : '~/.grok'
|
||||
const configContent = `[models]
|
||||
default = "sub2api-grok"
|
||||
web_search = "sub2api-grok"
|
||||
|
||||
[model."sub2api-grok"]
|
||||
model = "grok-4.5"
|
||||
base_url = "${baseUrl}"
|
||||
name = "Grok 4.5 via Sub2API"
|
||||
description = "Grok 4.5 through a Sub2API Grok group"
|
||||
api_key = "${apiKey}"
|
||||
api_backend = "responses"
|
||||
context_window = 1000000
|
||||
supports_backend_search = true`
|
||||
|
||||
return [{
|
||||
path: `${configDir}/config.toml`,
|
||||
content: configContent,
|
||||
hint: t('keys.useKeyModal.grok.configTomlHint')
|
||||
}]
|
||||
}
|
||||
|
||||
function generateOpenAIWsFiles(baseUrl: string, apiKey: string): FileConfig[] {
|
||||
const isWindows = activeTab.value === 'windows'
|
||||
const configDir = isWindows ? '%userprofile%\\.codex' : '~/.codex'
|
||||
@@ -1072,6 +1113,24 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
|
||||
}
|
||||
}
|
||||
}
|
||||
const grokModels = {
|
||||
'grok-4.5': {
|
||||
name: 'Grok 4.5',
|
||||
limit: { context: 1000000, output: 128000 }
|
||||
},
|
||||
'grok-4.3': {
|
||||
name: 'Grok 4.3',
|
||||
limit: { context: 1000000, output: 128000 }
|
||||
},
|
||||
'grok-build-0.1': {
|
||||
name: 'Grok Build 0.1',
|
||||
limit: { context: 256000, output: 128000 }
|
||||
},
|
||||
'grok-composer-2.5-fast': {
|
||||
name: 'Grok Composer 2.5 Fast',
|
||||
limit: { context: 500000, output: 128000 }
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'gemini') {
|
||||
provider[platform].npm = '@ai-sdk/google'
|
||||
@@ -1088,6 +1147,10 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
|
||||
provider[platform].models = antigravityGeminiModels
|
||||
} else if (platform === 'openai') {
|
||||
provider[platform].models = openaiModels
|
||||
} else if (platform === 'grok') {
|
||||
provider[platform].npm = '@ai-sdk/openai'
|
||||
provider[platform].name = 'Grok via Sub2API'
|
||||
provider[platform].models = grokModels
|
||||
}
|
||||
|
||||
const agent =
|
||||
|
||||
@@ -17,6 +17,67 @@ vi.mock('@/composables/useClipboard', () => ({
|
||||
import UseKeyModal from '../UseKeyModal.vue'
|
||||
|
||||
describe('UseKeyModal', () => {
|
||||
it('renders Grok Build and OpenCode setup for Grok groups', async () => {
|
||||
const wrapper = mount(UseKeyModal, {
|
||||
props: {
|
||||
show: true,
|
||||
apiKey: 'sk-grok-test',
|
||||
baseUrl: 'https://example.com/v1',
|
||||
platform: 'grok'
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: {
|
||||
template: '<div><slot /><slot name="footer" /></div>'
|
||||
},
|
||||
Icon: {
|
||||
template: '<span />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const grokTab = wrapper.findAll('button').find((button) =>
|
||||
button.text().includes('keys.useKeyModal.cliTabs.grokCli')
|
||||
)
|
||||
expect(grokTab).toBeDefined()
|
||||
|
||||
const grokConfig = wrapper.findAll('pre code')
|
||||
.map((code) => code.text())
|
||||
.find((content) => content.includes('[model."sub2api-grok"]'))
|
||||
expect(grokConfig).toBeDefined()
|
||||
expect(grokConfig).toContain('model = "grok-4.5"')
|
||||
expect(grokConfig).toContain('base_url = "https://example.com/v1"')
|
||||
expect(grokConfig).toContain('api_key = "sk-grok-test"')
|
||||
expect(grokConfig).toContain('api_backend = "responses"')
|
||||
|
||||
const windowsTab = wrapper.findAll('button').find(
|
||||
(button) => button.text().trim() === 'Windows'
|
||||
)
|
||||
expect(windowsTab).toBeDefined()
|
||||
await windowsTab!.trigger('click')
|
||||
await nextTick()
|
||||
expect(wrapper.text()).toContain('%userprofile%\\.grok/config.toml')
|
||||
|
||||
const opencodeTab = wrapper.findAll('button').find((button) =>
|
||||
button.text().includes('keys.useKeyModal.cliTabs.opencode')
|
||||
)
|
||||
expect(opencodeTab).toBeDefined()
|
||||
await opencodeTab!.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const parsed = JSON.parse(wrapper.find('pre code').text())
|
||||
expect(parsed.provider.grok.npm).toBe('@ai-sdk/openai')
|
||||
expect(parsed.provider.grok.options).toEqual({
|
||||
baseURL: 'https://example.com/v1',
|
||||
apiKey: 'sk-grok-test'
|
||||
})
|
||||
expect(parsed.provider.grok.models['grok-4.5']).toBeDefined()
|
||||
expect(parsed.provider.grok.models['grok-build-0.1']).toBeDefined()
|
||||
expect(parsed.provider.grok.models['grok-composer-2.5-fast']).toBeDefined()
|
||||
expect(parsed.provider.grok.models['gpt-5.6']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('renders GPT-5.5 and goals feature in OpenAI Codex config', () => {
|
||||
const wrapper = mount(UseKeyModal, {
|
||||
props: {
|
||||
|
||||
@@ -53,3 +53,20 @@ describe('useGrokOAuth.exchangeAuthCode', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useGrokOAuth.buildCredentials', () => {
|
||||
it('persists the Grok CLI subscription proxy for OAuth inference', () => {
|
||||
const oauth = useGrokOAuth()
|
||||
|
||||
const credentials = oauth.buildCredentials({
|
||||
access_token: 'access-token',
|
||||
token_type: 'Bearer',
|
||||
expires_at: 1_900_000_000,
|
||||
client_id: 'client-id',
|
||||
scope: 'openid grok-cli:access',
|
||||
email: 'grok@example.com'
|
||||
})
|
||||
|
||||
expect(credentials.base_url).toBe('https://cli-chat-proxy.grok.com/v1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -122,7 +122,8 @@ export function useGrokOAuth() {
|
||||
scope: tokenInfo.scope,
|
||||
email: tokenInfo.email,
|
||||
subscription_tier: tokenInfo.subscription_tier,
|
||||
entitlement_status: tokenInfo.entitlement_status
|
||||
entitlement_status: tokenInfo.entitlement_status,
|
||||
base_url: 'https://cli-chat-proxy.grok.com/v1'
|
||||
}
|
||||
if (tokenInfo.refresh_token) credentials.refresh_token = tokenInfo.refresh_token
|
||||
if (tokenInfo.id_token) credentials.id_token = tokenInfo.id_token
|
||||
|
||||
@@ -146,6 +146,7 @@ export default {
|
||||
geminiCli: 'Gemini CLI',
|
||||
codexCli: 'Codex CLI',
|
||||
codexCliWs: 'Codex CLI (WebSocket)',
|
||||
grokCli: 'Grok CLI',
|
||||
opencode: 'OpenCode',
|
||||
},
|
||||
antigravity: {
|
||||
@@ -160,6 +161,12 @@ export default {
|
||||
modelComment: 'If you have Gemini 3 access, you can use: gemini-3-pro-preview',
|
||||
note: 'These environment variables will be active in the current terminal session. For permanent configuration, add them to ~/.bashrc, ~/.zshrc, or the appropriate configuration file.',
|
||||
},
|
||||
grok: {
|
||||
description: 'Configure Grok Build or OpenCode to send Responses API traffic through your Sub2API Grok group.',
|
||||
configTomlHint: 'Back up an existing config.toml before merging this model entry. Run grok inspect after saving to verify the effective configuration.',
|
||||
note: 'Save the file as ~/.grok/config.toml, then run grok inspect and select sub2api-grok from /model.',
|
||||
noteWindows: 'Save the file as %USERPROFILE%\\.grok\\config.toml, then run grok inspect and select sub2api-grok from /model.',
|
||||
},
|
||||
opencode: {
|
||||
title: 'OpenCode Example',
|
||||
subtitle: 'opencode.json',
|
||||
|
||||
@@ -147,6 +147,7 @@ export default {
|
||||
geminiCli: 'Gemini CLI',
|
||||
codexCli: 'Codex CLI',
|
||||
codexCliWs: 'Codex CLI (WebSocket)',
|
||||
grokCli: 'Grok CLI',
|
||||
opencode: 'OpenCode'
|
||||
},
|
||||
antigravity: {
|
||||
@@ -164,6 +165,12 @@ export default {
|
||||
modelComment: '如果你有 Gemini 3 权限可以填:gemini-3-pro-preview',
|
||||
note: '这些环境变量将在当前终端会话中生效。如需永久配置,请将其添加到 ~/.bashrc、~/.zshrc 或相应的配置文件中。'
|
||||
},
|
||||
grok: {
|
||||
description: '配置 Grok Build 或 OpenCode,让 Responses API 请求通过当前 Sub2API Grok 分组发送。',
|
||||
configTomlHint: '如已有 config.toml,请先备份再合并此模型配置。保存后运行 grok inspect 验证生效配置。',
|
||||
note: '保存为 ~/.grok/config.toml,然后运行 grok inspect,并在 /model 中选择 sub2api-grok。',
|
||||
noteWindows: '保存为 %USERPROFILE%\\.grok\\config.toml,然后运行 grok inspect,并在 /model 中选择 sub2api-grok。'
|
||||
},
|
||||
opencode: {
|
||||
title: 'OpenCode 配置示例',
|
||||
subtitle: 'opencode.json',
|
||||
|
||||
Reference in New Issue
Block a user