feat(provider): add Grok official OAuth support

This commit is contained in:
程序员阿江(Relakkes) 2026-07-13 21:24:46 +08:00
parent 5d3c126d50
commit 5178d7ceec
61 changed files with 3092 additions and 65 deletions

View File

@ -88,6 +88,10 @@ vi.mock('../components/settings/ChatGPTOfficialLogin', () => ({
ChatGPTOfficialLogin: () => <div data-testid="chatgpt-official-login" />,
}))
vi.mock('../components/settings/GrokOfficialLogin', () => ({
GrokOfficialLogin: () => <div data-testid="grok-official-login" />,
}))
vi.mock('../pages/AdapterSettings', () => ({
AdapterSettings: () => <div>Adapter Settings Mock</div>,
}))
@ -1571,7 +1575,7 @@ describe('Settings > Providers tab', () => {
notes: '',
},
]
providerStoreState.providerOrder = ['provider-1', 'claude-official', 'openai-official']
providerStoreState.providerOrder = ['provider-1', 'claude-official', 'openai-official', 'grok-official']
providerStoreState.activeId = null
providerStoreState.hasLoadedProviders = true
})
@ -1620,6 +1624,18 @@ describe('Settings > Providers tab', () => {
expect(screen.queryByTestId('claude-official-login')).not.toBeInTheDocument()
})
it('shows Grok Official as the active built-in provider', () => {
providerStoreState.providers = []
providerStoreState.activeId = 'grok-official'
render(<Settings />)
const provider = screen.getByTestId('grok-official-provider')
expect(within(provider).getByText('Grok Official')).toBeInTheDocument()
expect(within(provider).getByText('Default')).toBeInTheDocument()
expect(screen.getByTestId('grok-official-login')).toBeInTheDocument()
})
it('renders saved and official providers in the stored sortable order', () => {
providerStoreState.providerOrder = ['provider-1', 'openai-official', 'claude-official']
@ -1631,6 +1647,7 @@ describe('Settings > Providers tab', () => {
'provider-provider-1',
'openai-official-provider',
'claude-official-provider',
'grok-official-provider',
])
})
@ -1645,6 +1662,7 @@ describe('Settings > Providers tab', () => {
'provider-provider-1',
'claude-official-provider',
'openai-official-provider',
'grok-official-provider',
])
})

View File

@ -0,0 +1,39 @@
import { api, getBaseUrl } from './client'
export type HahaGrokOAuthStatus =
| { loggedIn: false }
| {
loggedIn: true
expiresAt: number | null
email: string | null
}
function currentServerPort(): number {
const port = new URL(getBaseUrl()).port
const parsed = Number.parseInt(port, 10)
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`Cannot determine server port from baseUrl: ${getBaseUrl()}`)
}
return parsed
}
export const hahaGrokOAuthApi = {
start() {
return api.post<{ authorizeUrl: string; state: string }>(
'/api/haha-grok-oauth/start',
{ serverPort: currentServerPort() },
)
},
status() {
return api.get<HahaGrokOAuthStatus>('/api/haha-grok-oauth')
},
successUrl() {
return `${getBaseUrl()}/api/haha-grok-oauth/success`
},
logout() {
return api.delete<{ ok: true }>('/api/haha-grok-oauth')
},
}

View File

@ -18,7 +18,7 @@ type PresetsResponse = { presets: ProviderPreset[] }
type TestResultResponse = { result: ProviderTestResult }
type AuthStatusResponse = {
hasAuth: boolean
source: 'cc-haha-provider' | 'openai-oauth' | 'original-settings' | 'env' | 'none'
source: 'cc-haha-provider' | 'openai-oauth' | 'grok-oauth' | 'original-settings' | 'env' | 'none'
activeProvider?: string
}

View File

@ -1,11 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { ModelSelector } from './ModelSelector'
import { useChatStore } from '../../stores/chatStore'
import { useHahaOAuthStore } from '../../stores/hahaOAuthStore'
import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore'
import { useHahaGrokOAuthStore } from '../../stores/hahaGrokOAuthStore'
import { useProviderStore } from '../../stores/providerStore'
import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore'
import { useSettingsStore } from '../../stores/settingsStore'
@ -32,11 +33,13 @@ afterEach(() => {
useChatStore.setState(useChatStore.getInitialState(), true)
useHahaOAuthStore.setState(useHahaOAuthStore.getInitialState(), true)
useHahaOpenAIOAuthStore.setState(useHahaOpenAIOAuthStore.getInitialState(), true)
useHahaGrokOAuthStore.setState(useHahaGrokOAuthStore.getInitialState(), true)
})
beforeEach(() => {
useHahaOAuthStore.setState({ fetchStatus: async () => {} })
useHahaOpenAIOAuthStore.setState({ fetchStatus: async () => {} })
useHahaGrokOAuthStore.setState({ fetchStatus: async () => {} })
})
describe('ModelSelector', () => {
@ -453,9 +456,93 @@ describe('ModelSelector', () => {
})
})
it('selects Grok Official models for a logged-in runtime', async () => {
const grokModels: ModelInfo[] = [{
id: 'grok-4.5',
name: 'Grok 4.5',
description: 'Grok frontier text model',
context: '',
supportedReasoningEfforts: [],
}]
useHahaGrokOAuthStore.setState({
status: { loggedIn: true, expiresAt: null, email: 'grok@example.com' },
fetchStatus: async () => {},
})
useSettingsStore.setState({
locale: 'en',
availableModels: grokModels,
currentModel: grokModels[0],
activeProviderName: 'Grok Official',
})
useProviderStore.setState({
providers: [],
activeId: 'grok-official',
hasLoadedProviders: true,
isLoading: false,
})
render(<ModelSelector runtimeKey="session-grok" />)
await clickByRole(/Grok 4\.5/i)
await act(async () => {
fireEvent.click(screen.getAllByRole('button', { name: /Grok 4\.5/i })[1]!)
await Promise.resolve()
})
expect(useSessionRuntimeStore.getState().selections['session-grok']).toMatchObject({
providerId: 'grok-official',
modelId: 'grok-4.5',
})
expect(screen.queryByRole('button', { name: /Effort:/i })).not.toBeInTheDocument()
})
it('replaces a stale Grok runtime model with the current official default', async () => {
const grokModels: ModelInfo[] = [{
id: 'grok-4.5',
name: 'Grok 4.5',
description: 'Grok frontier text model',
context: '500000',
defaultReasoningEffort: 'high',
supportedReasoningEfforts: ['low', 'medium', 'high'],
}]
useHahaGrokOAuthStore.setState({
status: { loggedIn: true, expiresAt: null, email: 'grok@example.com' },
fetchStatus: async () => {},
})
useSettingsStore.setState({
locale: 'en',
availableModels: grokModels,
currentModel: grokModels[0],
activeProviderName: 'Grok Official',
effortLevel: 'max',
})
useProviderStore.setState({
providers: [],
activeId: 'grok-official',
hasLoadedProviders: true,
isLoading: false,
})
useSessionRuntimeStore.getState().setSelection('session-stale-grok', {
providerId: 'grok-official',
modelId: 'grok-build',
effortLevel: 'max',
})
render(<ModelSelector runtimeKey="session-stale-grok" />)
expect(screen.queryByText('grok-build')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Grok 4.5, Grok Official' })).toBeInTheDocument()
await waitFor(() => {
expect(useSessionRuntimeStore.getState().selections['session-stale-grok']).toEqual({
providerId: 'grok-official',
modelId: 'grok-4.5',
effortLevel: 'high',
})
})
})
it('hides official provider sections when OAuth is not logged in', async () => {
useHahaOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: async () => {} })
useHahaOpenAIOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: async () => {} })
useHahaGrokOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: async () => {} })
useSettingsStore.setState({
locale: 'en',
availableModels: MODELS,

View File

@ -18,6 +18,11 @@ import { isDesktopRuntime } from '../../lib/desktopRuntime'
import { resolveDefaultRuntimeSelection } from '../../lib/runtimeSelection'
import { useHahaOAuthStore } from '../../stores/hahaOAuthStore'
import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore'
import { useHahaGrokOAuthStore } from '../../stores/hahaGrokOAuthStore'
import {
GROK_OFFICIAL_MODELS,
GROK_OFFICIAL_PROVIDER_ID,
} from '../../constants/grokOfficialProvider'
import { MobileBottomSheet } from '../shared/MobileBottomSheet'
import { ReasoningEffortPopover } from './ReasoningEffortPopover'
@ -108,9 +113,11 @@ function buildProviderChoices(
availableModels: ModelInfo[],
officialName: string,
openAIOfficialName: string,
grokOfficialName: string,
labels: Record<'main' | 'haiku' | 'sonnet' | 'opus', string>,
claudeOfficialLoggedIn: boolean,
openAIOfficialLoggedIn: boolean,
grokOfficialLoggedIn: boolean,
): ProviderChoice[] {
const claudeOfficialModels = activeId === null && availableModels.length > 0
? availableModels
@ -118,6 +125,9 @@ function buildProviderChoices(
const openAIOfficialModels = activeId === OPENAI_OFFICIAL_PROVIDER_ID && availableModels.length > 0
? availableModels
: OPENAI_OFFICIAL_MODELS
const grokOfficialModels = activeId === GROK_OFFICIAL_PROVIDER_ID && availableModels.length > 0
? availableModels
: GROK_OFFICIAL_MODELS
const choices: ProviderChoice[] = []
@ -132,6 +142,14 @@ function buildProviderChoices(
openAIOfficialName,
))
}
if (grokOfficialLoggedIn) {
choices.push(officialChoices(
GROK_OFFICIAL_PROVIDER_ID,
grokOfficialModels,
activeId === GROK_OFFICIAL_PROVIDER_ID,
grokOfficialName,
))
}
for (const provider of providers) {
choices.push({
@ -173,6 +191,8 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
const fetchClaudeOAuthStatus = useHahaOAuthStore((s) => s.fetchStatus)
const openAIOAuthStatus = useHahaOpenAIOAuthStore((s) => s.status)
const fetchOpenAIOAuthStatus = useHahaOpenAIOAuthStore((s) => s.fetchStatus)
const grokOAuthStatus = useHahaGrokOAuthStore((s) => s.status)
const fetchGrokOAuthStatus = useHahaGrokOAuthStore((s) => s.fetchStatus)
const runtimeSelection = useSessionRuntimeStore((state) =>
runtimeKey ? state.selections[runtimeKey] : undefined,
)
@ -217,7 +237,8 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
requestedOAuthStatusRef.current = true
void fetchClaudeOAuthStatus()
void fetchOpenAIOAuthStatus()
}, [fetchClaudeOAuthStatus, fetchOpenAIOAuthStatus, isRuntimeScoped, open])
void fetchGrokOAuthStatus()
}, [fetchClaudeOAuthStatus, fetchGrokOAuthStatus, fetchOpenAIOAuthStatus, isRuntimeScoped, open])
const openSelector = useCallback(() => {
if (!disabled) {
@ -318,11 +339,13 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
availableModels,
t('settings.providers.officialName'),
t('settings.providers.openaiOfficialName'),
t('settings.providers.grokOfficialName'),
roleLabels,
claudeOAuthStatus?.loggedIn === true,
openAIOAuthStatus?.loggedIn === true,
grokOAuthStatus?.loggedIn === true,
),
[activeId, availableModels, providers, roleLabels, t, claudeOAuthStatus, openAIOAuthStatus],
[activeId, availableModels, providers, roleLabels, t, claudeOAuthStatus, grokOAuthStatus, openAIOAuthStatus],
)
const selectedModel = isControlled
@ -358,13 +381,15 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
const buttonProviderLabel = isRuntimeScoped
? selectedProviderChoice?.providerName ?? activeProviderName ?? t('settings.providers.officialName')
: null
const selectedRuntimeEffort = activeRuntimeSelection?.effortLevel
?? selectedRuntimeModel?.defaultReasoningEffort
?? effortLevel
const supportedRuntimeEfforts = selectedRuntimeModel?.supportedReasoningEfforts
const runtimeEffortOptions = supportedRuntimeEfforts?.length
? EFFORT_OPTIONS.filter((option) => supportedRuntimeEfforts.includes(option.value))
: EFFORT_OPTIONS.filter((option) => option.value !== 'xhigh')
const selectedRuntimeEffort = supportedRuntimeEfforts?.length === 0
? undefined
: activeRuntimeSelection?.effortLevel
?? selectedRuntimeModel?.defaultReasoningEffort
?? effortLevel
const runtimeEffortOptions = supportedRuntimeEfforts === undefined
? EFFORT_OPTIONS.filter((option) => option.value !== 'xhigh')
: EFFORT_OPTIONS.filter((option) => supportedRuntimeEfforts.includes(option.value))
const handleRuntimeSelect = (selection: RuntimeSelection) => {
onRuntimeSelectionChange?.(selection)
@ -420,11 +445,13 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
onClick={() => {
const supportedEfforts = model.supportedReasoningEfforts
const explicitEffort = activeRuntimeSelection?.effortLevel
const nextEffort = supportedEfforts?.length
? explicitEffort && supportedEfforts.includes(explicitEffort)
? explicitEffort
: model.defaultReasoningEffort ?? supportedEfforts[0]
: explicitEffort ?? effortLevel
const nextEffort = supportedEfforts === undefined
? explicitEffort ?? effortLevel
: supportedEfforts.length
? explicitEffort && supportedEfforts.includes(explicitEffort)
? explicitEffort
: model.defaultReasoningEffort ?? supportedEfforts[0]
: undefined
handleRuntimeSelect({
providerId: choice.providerId,
modelId: model.id,

View File

@ -0,0 +1,101 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
const { copyMock, logoutMock, startMock, statusMock } = vi.hoisted(() => ({
copyMock: vi.fn(),
logoutMock: vi.fn(),
startMock: vi.fn(),
statusMock: vi.fn(),
}))
vi.mock('../../api/hahaGrokOAuth', () => ({
hahaGrokOAuthApi: {
start: startMock,
status: statusMock,
logout: logoutMock,
successUrl: () => 'http://127.0.0.1:3456/api/haha-grok-oauth/success',
},
}))
vi.mock('../chat/clipboard', () => ({ copyTextToClipboard: copyMock }))
import { GrokOfficialLogin } from './GrokOfficialLogin'
import { useHahaGrokOAuthStore } from '../../stores/hahaGrokOAuthStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { browserHost } from '../../lib/desktopHost/browserHost'
const initialOAuthState = useHahaGrokOAuthStore.getState()
describe('GrokOfficialLogin', () => {
beforeEach(() => {
statusMock.mockResolvedValue({ loggedIn: false })
startMock.mockResolvedValue({
authorizeUrl: 'https://accounts.x.ai/oauth/authorize?state=grok-state',
state: 'grok-state',
})
copyMock.mockResolvedValue(true)
useSettingsStore.setState({ locale: 'en' })
useHahaGrokOAuthStore.setState({
...initialOAuthState,
status: null,
isPolling: false,
isLoading: false,
error: null,
})
})
afterEach(() => {
useHahaGrokOAuthStore.getState().stopPolling()
useHahaGrokOAuthStore.setState(initialOAuthState)
Reflect.deleteProperty(window, 'desktopHost')
cleanup()
vi.restoreAllMocks()
})
it('keeps a copyable authorization link when opening the browser fails', async () => {
const open = vi.fn().mockRejectedValue(new Error('shell unavailable'))
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
capabilities: { ...browserHost.capabilities, shell: true },
shell: { ...browserHost.shell, open },
}
vi.spyOn(console, 'error').mockImplementation(() => {})
render(<GrokOfficialLogin />)
await screen.findByRole('button', { name: 'Sign in with Grok' })
await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Sign in with Grok' })))
expect(open).toHaveBeenCalled()
expect(screen.getByText(/Unable to open browser/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Copy authorization link' })).toBeInTheDocument()
})
it('opens the local success page when authorization completes', async () => {
const open = vi.fn().mockResolvedValue(undefined)
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
capabilities: { ...browserHost.capabilities, shell: true },
shell: { ...browserHost.shell, open },
}
render(<GrokOfficialLogin />)
await screen.findByRole('button', { name: 'Sign in with Grok' })
await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Sign in with Grok' })))
expect(open).toHaveBeenCalledWith(expect.stringContaining('accounts.x.ai'))
act(() => {
useHahaGrokOAuthStore.setState({
status: { loggedIn: true, expiresAt: Date.now() + 60_000, email: 'grok@example.com' },
})
})
await waitFor(() => {
expect(open).toHaveBeenCalledWith('http://127.0.0.1:3456/api/haha-grok-oauth/success')
})
})
})

View File

@ -0,0 +1,127 @@
import { useEffect, useState } from 'react'
import { Copy, LogIn, LogOut } from 'lucide-react'
import { useHahaGrokOAuthStore } from '../../stores/hahaGrokOAuthStore'
import { useTranslation } from '../../i18n'
import { copyTextToClipboard } from '../chat/clipboard'
import { getDesktopHost } from '../../lib/desktopHost'
import { hahaGrokOAuthApi } from '../../api/hahaGrokOAuth'
export function GrokOfficialLogin() {
const t = useTranslation()
const [manualAuthorizeUrl, setManualAuthorizeUrl] = useState<string | null>(null)
const [isAwaitingAuthorization, setIsAwaitingAuthorization] = useState(false)
const { status, isLoading, error, fetchStatus, login, logout, startPolling, stopPolling } =
useHahaGrokOAuthStore()
useEffect(() => {
void fetchStatus()
return () => stopPolling()
}, [fetchStatus, stopPolling])
useEffect(() => {
if (status?.loggedIn) setManualAuthorizeUrl(null)
}, [status?.loggedIn])
useEffect(() => {
if (!status?.loggedIn || !isAwaitingAuthorization) return
setIsAwaitingAuthorization(false)
void getDesktopHost().shell.open(hahaGrokOAuthApi.successUrl()).catch((err) => {
console.error('[GrokOfficialLogin] success page open failed:', err)
})
}, [isAwaitingAuthorization, status?.loggedIn])
const handleLogin = async () => {
setManualAuthorizeUrl(null)
try {
const { authorizeUrl } = await login()
setManualAuthorizeUrl(authorizeUrl)
try {
await getDesktopHost().shell.open(authorizeUrl)
setManualAuthorizeUrl(null)
setIsAwaitingAuthorization(true)
startPolling()
} catch (err) {
console.error('[GrokOfficialLogin] shellOpen failed:', err)
useHahaGrokOAuthStore.setState({
error: t('settings.grokOfficialLogin.openBrowserFailed'),
})
}
} catch {
// Store owns request errors.
}
}
const handleCopyAuthorizeUrl = async () => {
if (!manualAuthorizeUrl) return
if (await copyTextToClipboard(manualAuthorizeUrl)) {
setManualAuthorizeUrl(null)
setIsAwaitingAuthorization(true)
useHahaGrokOAuthStore.setState({ error: null })
startPolling()
} else {
useHahaGrokOAuthStore.setState({
error: t('settings.grokOfficialLogin.copyLinkFailed'),
})
}
}
const manualAuthorizeButton = manualAuthorizeUrl ? (
<button
type="button"
onClick={handleCopyAuthorizeUrl}
className="inline-flex items-center gap-1.5 self-start rounded-md border border-[var(--color-border-separator)] bg-[var(--color-surface)] px-3 py-1.5 text-xs transition-colors hover:bg-[var(--color-surface-hover)]"
>
<Copy className="h-3.5 w-3.5" aria-hidden="true" />
{t('settings.grokOfficialLogin.copyAuthorizeUrl')}
</button>
) : null
if (status === null) {
return (
<div data-testid="grok-official-login" className="flex flex-col gap-2 text-xs">
{error ? (
<div className="text-[var(--color-error)]">{t('settings.grokOfficialLogin.errorPrefix')}{error}</div>
) : (
<div className="text-[var(--color-text-tertiary)]">{t('common.loading')}</div>
)}
{manualAuthorizeButton}
</div>
)
}
if (status.loggedIn) {
return (
<div data-testid="grok-official-login" className="flex items-center gap-3 text-sm">
<span className="text-[var(--color-success)]">
{t('settings.grokOfficialLogin.loggedInPrefix')} {status.email || t('settings.grokOfficialLogin.accountUnknown')}
</span>
<button
type="button"
onClick={logout}
disabled={isLoading}
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--color-border-separator)] bg-[var(--color-surface)] px-3 py-1 text-xs transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
>
<LogOut className="h-3.5 w-3.5" aria-hidden="true" />
{isLoading ? t('settings.grokOfficialLogin.logoutProcessing') : t('settings.grokOfficialLogin.logoutButton')}
</button>
</div>
)
}
return (
<div data-testid="grok-official-login" className="flex flex-col gap-2">
<div className="text-sm text-[var(--color-text-secondary)]">{t('settings.grokOfficialLogin.intro')}</div>
<button
type="button"
onClick={handleLogin}
disabled={isLoading}
className="inline-flex items-center gap-2 self-start rounded-md bg-[image:var(--gradient-btn-primary)] px-4 py-2 text-sm text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)] transition-opacity hover:brightness-105 disabled:opacity-50"
>
<LogIn className="h-4 w-4" aria-hidden="true" />
{isLoading ? t('settings.grokOfficialLogin.loginStarting') : t('settings.grokOfficialLogin.loginButton')}
</button>
{error && <div className="text-xs text-[var(--color-error)]">{t('settings.grokOfficialLogin.errorPrefix')}{error}</div>}
{manualAuthorizeButton}
</div>
)
}

View File

@ -0,0 +1,23 @@
import type { ModelInfo } from '../types/settings'
export const GROK_OFFICIAL_PROVIDER_ID = 'grok-official'
export const GROK_OFFICIAL_DEFAULT_MODEL_ID = 'grok-4.5'
export const GROK_OFFICIAL_PROVIDER_NAME = 'Grok Official'
export const GROK_OFFICIAL_MODELS: ModelInfo[] = [
{
id: GROK_OFFICIAL_DEFAULT_MODEL_ID,
name: 'Grok 4.5',
description: 'Grok frontier text model',
context: '500000',
defaultReasoningEffort: 'high',
supportedReasoningEfforts: ['low', 'medium', 'high'],
},
{
id: 'grok-composer-2.5-fast',
name: 'Composer 2.5',
description: 'Grok coding model',
context: '200000',
supportedReasoningEfforts: [],
},
]

View File

@ -1,10 +1,12 @@
import type { ModelInfo } from '../types/settings'
import { GROK_OFFICIAL_PROVIDER_ID } from './grokOfficialProvider'
export const CLAUDE_OFFICIAL_PROVIDER_ID = 'claude-official'
export const OPENAI_OFFICIAL_PROVIDER_ID = 'openai-official'
export const BUILT_IN_PROVIDER_IDS = [
CLAUDE_OFFICIAL_PROVIDER_ID,
OPENAI_OFFICIAL_PROVIDER_ID,
GROK_OFFICIAL_PROVIDER_ID,
] as const
export const OPENAI_OFFICIAL_DEFAULT_MODEL_ID = 'gpt-5.6-sol'
export const OPENAI_OFFICIAL_PROVIDER_NAME = 'ChatGPT Official'

View File

@ -430,6 +430,17 @@ export const en = {
'settings.chatgptOfficialLogin.copyAuthorizeUrl': 'Copy authorization link',
'settings.chatgptOfficialLogin.copyLinkFailed': 'Unable to copy authorization link.',
'settings.chatgptOfficialLogin.errorPrefix': 'ChatGPT OAuth error: ',
'settings.grokOfficialLogin.intro': 'Sign in with Grok to use official Grok models from desktop sessions.',
'settings.grokOfficialLogin.loginButton': 'Sign in with Grok',
'settings.grokOfficialLogin.loginStarting': 'Starting sign-in...',
'settings.grokOfficialLogin.logoutButton': 'Sign out',
'settings.grokOfficialLogin.logoutProcessing': 'Signing out...',
'settings.grokOfficialLogin.loggedInPrefix': 'Signed in as',
'settings.grokOfficialLogin.accountUnknown': 'Grok account',
'settings.grokOfficialLogin.openBrowserFailed': 'Unable to open browser. Copy the authorization link and open it manually.',
'settings.grokOfficialLogin.copyAuthorizeUrl': 'Copy authorization link',
'settings.grokOfficialLogin.copyLinkFailed': 'Unable to copy authorization link.',
'settings.grokOfficialLogin.errorPrefix': 'Grok OAuth error: ',
// Settings > Providers
'settings.providers.title': 'Providers',
@ -439,6 +450,8 @@ export const en = {
'settings.providers.officialDesc': 'Anthropic native — no API key required',
'settings.providers.openaiOfficialName': 'ChatGPT Official',
'settings.providers.openaiOfficialDesc': 'OpenAI OAuth via your ChatGPT account — no API key required',
'settings.providers.grokOfficialName': 'Grok Official',
'settings.providers.grokOfficialDesc': 'Official Grok OAuth via your xAI account — no API key required',
'settings.providers.connected': 'Connected ({latency}ms)',
'settings.providers.failed': 'Failed: {error}',
'settings.providers.connectivityOk': '① Connectivity ({latency}ms)',

View File

@ -432,6 +432,17 @@ export const jp: Record<TranslationKey, string> = {
'settings.chatgptOfficialLogin.copyAuthorizeUrl': '承認リンクをコピー',
'settings.chatgptOfficialLogin.copyLinkFailed': '承認リンクをコピーできません。',
'settings.chatgptOfficialLogin.errorPrefix': 'ChatGPT OAuth エラー: ',
'settings.grokOfficialLogin.intro': 'デスクトップセッションで公式 Grok モデルを使用するには、Grok でサインインしてください。',
'settings.grokOfficialLogin.loginButton': 'Grok でサインイン',
'settings.grokOfficialLogin.loginStarting': 'サインインを開始中...',
'settings.grokOfficialLogin.logoutButton': 'サインアウト',
'settings.grokOfficialLogin.logoutProcessing': 'サインアウト中...',
'settings.grokOfficialLogin.loggedInPrefix': 'サインイン中:',
'settings.grokOfficialLogin.accountUnknown': 'Grok アカウント',
'settings.grokOfficialLogin.openBrowserFailed': 'ブラウザを開けません。承認リンクをコピーして手動で開いてください。',
'settings.grokOfficialLogin.copyAuthorizeUrl': '承認リンクをコピー',
'settings.grokOfficialLogin.copyLinkFailed': '承認リンクをコピーできません。',
'settings.grokOfficialLogin.errorPrefix': 'Grok OAuth エラー: ',
// Settings > Providers
'settings.providers.title': 'プロバイダー',
@ -441,6 +452,8 @@ export const jp: Record<TranslationKey, string> = {
'settings.providers.officialDesc': 'Anthropic ネイティブ — API キー不要',
'settings.providers.openaiOfficialName': 'ChatGPT 公式',
'settings.providers.openaiOfficialDesc': 'ChatGPT アカウントによる OpenAI OAuth — API キー不要',
'settings.providers.grokOfficialName': 'Grok 公式',
'settings.providers.grokOfficialDesc': 'xAI アカウントによる公式 Grok OAuth — API キー不要',
'settings.providers.connected': '接続済み ({latency}ms)',
'settings.providers.failed': '失敗: {error}',
'settings.providers.connectivityOk': '① 接続 ({latency}ms)',

View File

@ -432,6 +432,17 @@ export const kr: Record<TranslationKey, string> = {
'settings.chatgptOfficialLogin.copyAuthorizeUrl': '승인 링크 복사',
'settings.chatgptOfficialLogin.copyLinkFailed': '승인 링크를 복사할 수 없습니다.',
'settings.chatgptOfficialLogin.errorPrefix': 'ChatGPT OAuth 오류: ',
'settings.grokOfficialLogin.intro': '데스크톱 세션에서 공식 Grok 모델을 사용하려면 Grok으로 로그인하세요.',
'settings.grokOfficialLogin.loginButton': 'Grok으로 로그인',
'settings.grokOfficialLogin.loginStarting': '로그인 시작 중...',
'settings.grokOfficialLogin.logoutButton': '로그아웃',
'settings.grokOfficialLogin.logoutProcessing': '로그아웃 중...',
'settings.grokOfficialLogin.loggedInPrefix': '로그인 계정:',
'settings.grokOfficialLogin.accountUnknown': 'Grok 계정',
'settings.grokOfficialLogin.openBrowserFailed': '브라우저를 열 수 없습니다. 승인 링크를 복사하여 수동으로 여세요.',
'settings.grokOfficialLogin.copyAuthorizeUrl': '승인 링크 복사',
'settings.grokOfficialLogin.copyLinkFailed': '승인 링크를 복사할 수 없습니다.',
'settings.grokOfficialLogin.errorPrefix': 'Grok OAuth 오류: ',
// Settings > Providers
'settings.providers.title': '공급자',
@ -441,6 +452,8 @@ export const kr: Record<TranslationKey, string> = {
'settings.providers.officialDesc': 'Anthropic 네이티브 — API 키 불필요',
'settings.providers.openaiOfficialName': 'ChatGPT 공식',
'settings.providers.openaiOfficialDesc': 'ChatGPT 계정을 통한 OpenAI OAuth — API 키 불필요',
'settings.providers.grokOfficialName': 'Grok 공식',
'settings.providers.grokOfficialDesc': 'xAI 계정을 통한 공식 Grok OAuth — API 키 불필요',
'settings.providers.connected': '연결됨 ({latency}ms)',
'settings.providers.failed': '실패: {error}',
'settings.providers.connectivityOk': '① 연결 ({latency}ms)',

View File

@ -432,6 +432,17 @@ export const zh: Record<TranslationKey, string> = {
'settings.chatgptOfficialLogin.copyAuthorizeUrl': '複製授權連結',
'settings.chatgptOfficialLogin.copyLinkFailed': '無法複製授權連結。',
'settings.chatgptOfficialLogin.errorPrefix': 'ChatGPT OAuth 錯誤:',
'settings.grokOfficialLogin.intro': '登入 Grok 後,即可在桌面端會話中使用 Grok 官方模型。',
'settings.grokOfficialLogin.loginButton': '登入 Grok',
'settings.grokOfficialLogin.loginStarting': '正在啟動登入...',
'settings.grokOfficialLogin.logoutButton': '退出登入',
'settings.grokOfficialLogin.logoutProcessing': '正在退出...',
'settings.grokOfficialLogin.loggedInPrefix': '已登入',
'settings.grokOfficialLogin.accountUnknown': 'Grok 賬號',
'settings.grokOfficialLogin.openBrowserFailed': '無法開啟瀏覽器。請複製授權連結並手動開啟。',
'settings.grokOfficialLogin.copyAuthorizeUrl': '複製授權連結',
'settings.grokOfficialLogin.copyLinkFailed': '無法複製授權連結。',
'settings.grokOfficialLogin.errorPrefix': 'Grok OAuth 錯誤:',
// Settings > Providers
'settings.providers.title': '服務商',
@ -441,6 +452,8 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.officialDesc': 'Anthropic 原生接入 — 無需 API 金鑰',
'settings.providers.openaiOfficialName': 'ChatGPT 官方',
'settings.providers.openaiOfficialDesc': '透過 ChatGPT 賬號完成 OpenAI OAuth — 無需 API 金鑰',
'settings.providers.grokOfficialName': 'Grok 官方',
'settings.providers.grokOfficialDesc': '透過 xAI 賬號完成 Grok 官方 OAuth — 無需 API 金鑰',
'settings.providers.connected': '已連線 ({latency}ms)',
'settings.providers.failed': '失敗: {error}',
'settings.providers.connectivityOk': '① 連通 ({latency}ms)',

View File

@ -432,6 +432,17 @@ export const zh: Record<TranslationKey, string> = {
'settings.chatgptOfficialLogin.copyAuthorizeUrl': '复制授权链接',
'settings.chatgptOfficialLogin.copyLinkFailed': '无法复制授权链接。',
'settings.chatgptOfficialLogin.errorPrefix': 'ChatGPT OAuth 错误:',
'settings.grokOfficialLogin.intro': '登录 Grok 后,即可在桌面端会话中使用 Grok 官方模型。',
'settings.grokOfficialLogin.loginButton': '登录 Grok',
'settings.grokOfficialLogin.loginStarting': '正在启动登录...',
'settings.grokOfficialLogin.logoutButton': '退出登录',
'settings.grokOfficialLogin.logoutProcessing': '正在退出...',
'settings.grokOfficialLogin.loggedInPrefix': '已登录',
'settings.grokOfficialLogin.accountUnknown': 'Grok 账号',
'settings.grokOfficialLogin.openBrowserFailed': '无法打开浏览器。请复制授权链接并手动打开。',
'settings.grokOfficialLogin.copyAuthorizeUrl': '复制授权链接',
'settings.grokOfficialLogin.copyLinkFailed': '无法复制授权链接。',
'settings.grokOfficialLogin.errorPrefix': 'Grok OAuth 错误:',
// Settings > Providers
'settings.providers.title': '服务商',
@ -441,6 +452,8 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.officialDesc': 'Anthropic 原生接入 — 无需 API 密钥',
'settings.providers.openaiOfficialName': 'ChatGPT 官方',
'settings.providers.openaiOfficialDesc': '通过 ChatGPT 账号完成 OpenAI OAuth — 无需 API 密钥',
'settings.providers.grokOfficialName': 'Grok 官方',
'settings.providers.grokOfficialDesc': '通过 xAI 账号完成 Grok 官方 OAuth — 无需 API 密钥',
'settings.providers.connected': '已连接 ({latency}ms)',
'settings.providers.failed': '失败: {error}',
'settings.providers.connectivityOk': '① 连通 ({latency}ms)',

View File

@ -5,6 +5,10 @@ import {
} from '../constants/openaiOfficialProvider'
import type { SavedProvider } from '../types/provider'
import type { RuntimeSelection } from '../types/runtime'
import {
GROK_OFFICIAL_DEFAULT_MODEL_ID,
GROK_OFFICIAL_PROVIDER_ID,
} from '../constants/grokOfficialProvider'
export function resolveActiveProviderRuntimeSelection(
activeId: string | null,
@ -27,7 +31,9 @@ export function resolveActiveProviderRuntimeSelection(
modelId: providerMainModelId || currentModelId || (
inferredProviderId === OPENAI_OFFICIAL_PROVIDER_ID
? OPENAI_OFFICIAL_DEFAULT_MODEL_ID
: OFFICIAL_DEFAULT_MODEL_ID
: inferredProviderId === GROK_OFFICIAL_PROVIDER_ID
? GROK_OFFICIAL_DEFAULT_MODEL_ID
: OFFICIAL_DEFAULT_MODEL_ID
),
}
}

View File

@ -570,7 +570,7 @@ describe('EmptySession', () => {
toolSearchEnabled: true,
}],
activeId: 'provider-minimax',
providerOrder: ['provider-minimax', 'claude-official', 'openai-official'],
providerOrder: ['provider-minimax', 'claude-official', 'openai-official', 'grok-official'],
hasLoadedProviders: true,
})

View File

@ -52,11 +52,13 @@ import { MemorySettings } from './MemorySettings'
import { useUIStore } from '../stores/uiStore'
import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
import { ChatGPTOfficialLogin } from '../components/settings/ChatGPTOfficialLogin'
import { GrokOfficialLogin } from '../components/settings/GrokOfficialLogin'
import {
BUILT_IN_PROVIDER_IDS,
CLAUDE_OFFICIAL_PROVIDER_ID,
OPENAI_OFFICIAL_PROVIDER_ID,
} from '../constants/openaiOfficialProvider'
import { GROK_OFFICIAL_PROVIDER_ID } from '../constants/grokOfficialProvider'
import { useUpdateStore } from '../stores/updateStore'
import { getBaseUrl } from '../api/client'
import { formatBytes } from '../lib/formatBytes'
@ -279,6 +281,7 @@ function TabButton({ icon, label, active, onClick }: { icon: string; label: stri
type ProviderListItem =
| { id: typeof CLAUDE_OFFICIAL_PROVIDER_ID; kind: 'claude-official' }
| { id: typeof OPENAI_OFFICIAL_PROVIDER_ID; kind: 'openai-official' }
| { id: typeof GROK_OFFICIAL_PROVIDER_ID; kind: 'grok-official' }
| { id: string; kind: 'saved'; provider: SavedProvider }
function defaultProviderOrder(providers: SavedProvider[]): string[] {
@ -325,6 +328,7 @@ function buildProviderListItems(
const items = new Map<string, ProviderListItem>([
[CLAUDE_OFFICIAL_PROVIDER_ID, { id: CLAUDE_OFFICIAL_PROVIDER_ID, kind: 'claude-official' }],
[OPENAI_OFFICIAL_PROVIDER_ID, { id: OPENAI_OFFICIAL_PROVIDER_ID, kind: 'openai-official' }],
[GROK_OFFICIAL_PROVIDER_ID, { id: GROK_OFFICIAL_PROVIDER_ID, kind: 'grok-official' }],
...savedItems,
])
@ -339,6 +343,8 @@ function providerItemTestId(item: ProviderListItem): string {
return 'claude-official-provider'
case 'openai-official':
return 'openai-official-provider'
case 'grok-official':
return 'grok-official-provider'
case 'saved':
return `provider-${item.provider.id}`
}
@ -444,6 +450,7 @@ function ProviderSettings() {
const isClaudeOfficialActive = hasLoadedProviders && activeId === null
const isOpenAIOfficialActive = hasLoadedProviders && activeId === OPENAI_OFFICIAL_PROVIDER_ID
const isGrokOfficialActive = hasLoadedProviders && activeId === GROK_OFFICIAL_PROVIDER_ID
return (
<div className="max-w-2xl">
@ -513,6 +520,28 @@ function ProviderSettings() {
)
}
if (item.kind === 'grok-official') {
return (
<SortableProviderCard
key={item.id}
item={item}
isActive={isGrokOfficialActive}
dragLabel={t('settings.providers.dragToReorder')}
onActivate={!isGrokOfficialActive ? () => handleActivate(GROK_OFFICIAL_PROVIDER_ID) : undefined}
title={t('settings.providers.grokOfficialName')}
subtitle={t('settings.providers.grokOfficialDesc')}
badges={isGrokOfficialActive ? (
<span className="rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/12 px-1.5 py-0.5 text-[10px] font-bold leading-none text-[var(--color-brand)]">{t('settings.providers.default')}</span>
) : null}
details={isGrokOfficialActive ? (
<div className="border-t border-[var(--color-border-separator)] px-4 pb-4 pt-3">
<GrokOfficialLogin />
</div>
) : null}
/>
)
}
const provider = item.provider
const isActive = activeId === provider.id
const test = testResults[provider.id]

View File

@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { startMock, statusMock, logoutMock } = vi.hoisted(() => ({
startMock: vi.fn(),
statusMock: vi.fn(),
logoutMock: vi.fn(),
}))
vi.mock('../api/hahaGrokOAuth', () => ({
hahaGrokOAuthApi: {
start: startMock,
status: statusMock,
logout: logoutMock,
},
}))
import { useHahaGrokOAuthStore } from './hahaGrokOAuthStore'
const initialState = useHahaGrokOAuthStore.getState()
describe('hahaGrokOAuthStore', () => {
beforeEach(() => {
vi.useFakeTimers()
startMock.mockReset()
statusMock.mockReset()
logoutMock.mockReset()
useHahaGrokOAuthStore.setState({
...initialState,
status: null,
isPolling: false,
isLoading: false,
error: null,
})
})
afterEach(() => {
useHahaGrokOAuthStore.getState().stopPolling()
useHahaGrokOAuthStore.setState(initialState)
vi.useRealTimers()
})
it('returns the authorization URL without polling before the browser opens', async () => {
startMock.mockResolvedValue({
authorizeUrl: 'https://accounts.x.ai/oauth/authorize?state=grok-state',
state: 'grok-state',
})
const result = await useHahaGrokOAuthStore.getState().login()
expect(result.authorizeUrl).toContain('state=grok-state')
expect(useHahaGrokOAuthStore.getState().isPolling).toBe(false)
})
it('stops polling after Grok OAuth becomes logged in', async () => {
statusMock
.mockResolvedValueOnce({ loggedIn: false })
.mockResolvedValueOnce({
loggedIn: true,
expiresAt: Date.now() + 60_000,
email: 'grok@example.com',
})
useHahaGrokOAuthStore.getState().startPolling()
await vi.advanceTimersByTimeAsync(4_000)
expect(useHahaGrokOAuthStore.getState().status).toMatchObject({
loggedIn: true,
email: 'grok@example.com',
})
expect(useHahaGrokOAuthStore.getState().isPolling).toBe(false)
})
})

View File

@ -0,0 +1,91 @@
import { create } from 'zustand'
import { hahaGrokOAuthApi, type HahaGrokOAuthStatus } from '../api/hahaGrokOAuth'
const POLL_INTERVAL_MS = 2_000
type HahaGrokOAuthState = {
status: HahaGrokOAuthStatus | null
isPolling: boolean
isLoading: boolean
error: string | null
fetchStatus: () => Promise<void>
login: () => Promise<{ authorizeUrl: string }>
logout: () => Promise<void>
startPolling: () => void
stopPolling: () => void
}
export const useHahaGrokOAuthStore = create<HahaGrokOAuthState>((set, get) => {
let pollTimer: ReturnType<typeof setTimeout> | null = null
return {
status: null,
isPolling: false,
isLoading: false,
error: null,
fetchStatus: async () => {
try {
set({ status: await hahaGrokOAuthApi.status(), error: null })
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err) })
}
},
login: async () => {
set({ isLoading: true, error: null })
try {
const result = await hahaGrokOAuthApi.start()
set({ isLoading: false })
return { authorizeUrl: result.authorizeUrl }
} catch (err) {
set({
isLoading: false,
error: err instanceof Error ? err.message : String(err),
})
throw err
}
},
logout: async () => {
get().stopPolling()
set({ isLoading: true, error: null })
try {
await hahaGrokOAuthApi.logout()
set({ status: { loggedIn: false }, isLoading: false })
} catch (err) {
set({
isLoading: false,
error: err instanceof Error ? err.message : String(err),
})
throw err
}
},
startPolling: () => {
if (pollTimer) return
set({ isPolling: true })
const scheduleNext = () => {
pollTimer = setTimeout(async () => {
pollTimer = null
await get().fetchStatus()
if (get().status?.loggedIn) {
get().stopPolling()
} else if (get().isPolling) {
scheduleNext()
}
}, POLL_INTERVAL_MS)
}
scheduleNext()
},
stopPolling: () => {
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
set({ isPolling: false })
},
}
})

View File

@ -168,6 +168,17 @@ describe('providerStore runtime refresh', () => {
expect(settingsFetchAllMock).toHaveBeenCalled()
})
it('sets the Grok default model when activating built-in Grok Official', async () => {
providersApiMock.activate.mockResolvedValue({ ok: true })
providersApiMock.list.mockResolvedValue({ providers: [], activeId: 'grok-official' })
const { useProviderStore } = await import('./providerStore')
await useProviderStore.getState().activateProvider('grok-official')
expect(settingsSetModelMock).toHaveBeenCalledWith('grok-4.5')
expect(settingsFetchAllMock).toHaveBeenCalled()
})
it('sets the provider main model when activating a saved provider', async () => {
const provider = makeProvider()
providersApiMock.activate.mockResolvedValue({ ok: true })
@ -239,20 +250,20 @@ describe('providerStore reorderProviders', () => {
const b = makeProvider({ id: 'b', name: 'B' })
providersApiMock.reorder.mockResolvedValue({
providers: [b, a],
providerOrder: ['openai-official', 'b', 'claude-official', 'a'],
providerOrder: ['openai-official', 'b', 'claude-official', 'a', 'grok-official'],
})
const { useProviderStore } = await import('./providerStore')
useProviderStore.setState({
providers: [a, b],
providerOrder: ['a', 'b', 'claude-official', 'openai-official'],
providerOrder: ['a', 'b', 'claude-official', 'openai-official', 'grok-official'],
activeId: null,
})
await useProviderStore.getState().reorderProviders(['openai-official', 'b', 'claude-official', 'a'])
await useProviderStore.getState().reorderProviders(['openai-official', 'b', 'claude-official', 'a', 'grok-official'])
expect(providersApiMock.reorder).toHaveBeenCalledWith(['openai-official', 'b', 'claude-official', 'a'])
expect(useProviderStore.getState().providerOrder).toEqual(['openai-official', 'b', 'claude-official', 'a'])
expect(providersApiMock.reorder).toHaveBeenCalledWith(['openai-official', 'b', 'claude-official', 'a', 'grok-official'])
expect(useProviderStore.getState().providerOrder).toEqual(['openai-official', 'b', 'claude-official', 'a', 'grok-official'])
expect(useProviderStore.getState().providers.map((p) => p.id)).toEqual(['b', 'a'])
})

View File

@ -11,6 +11,10 @@ import {
OPENAI_OFFICIAL_DEFAULT_MODEL_ID,
OPENAI_OFFICIAL_PROVIDER_ID,
} from '../constants/openaiOfficialProvider'
import {
GROK_OFFICIAL_DEFAULT_MODEL_ID,
GROK_OFFICIAL_PROVIDER_ID,
} from '../constants/grokOfficialProvider'
import type {
SavedProvider,
CreateProviderInput,
@ -268,6 +272,11 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
await settings.fetchAll()
return
}
if (id === GROK_OFFICIAL_PROVIDER_ID) {
await settings.setModel(GROK_OFFICIAL_DEFAULT_MODEL_ID)
await settings.fetchAll()
return
}
const provider = get().providers.find((p) => p.id === id)
if (!provider) return

View File

@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SessionListItem } from '../types/session'
import { useSessionRuntimeStore } from './sessionRuntimeStore'
const EXPECTED_GROK_SELECTION = {
providerId: 'grok-official',
modelId: 'grok-4.5',
effortLevel: 'high',
}
describe('sessionRuntimeStore Grok runtime cleanup', () => {
beforeEach(() => {
localStorage.clear()
useSessionRuntimeStore.setState({ selections: {} })
})
it('discards retired Grok selections before persisting them', () => {
useSessionRuntimeStore.getState().setSelection('session-grok', {
providerId: 'grok-official',
modelId: 'grok-build',
effortLevel: 'max',
})
expect(useSessionRuntimeStore.getState().selections['session-grok']).toEqual(
EXPECTED_GROK_SELECTION,
)
expect(JSON.parse(localStorage.getItem('cc-haha-session-runtime')!)).toEqual({
'session-grok': EXPECTED_GROK_SELECTION,
})
})
it('does not let retired Grok session metadata restore the removed model', () => {
useSessionRuntimeStore.getState().syncFromSessions([{
id: 'session-restored-grok',
runtimeProviderId: 'grok-official',
runtimeModelId: 'grok-build',
effortLevel: 'max',
} as SessionListItem])
expect(useSessionRuntimeStore.getState().selections['session-restored-grok']).toEqual(
EXPECTED_GROK_SELECTION,
)
})
it('cleans a retired Grok selection loaded from localStorage', async () => {
localStorage.setItem('cc-haha-session-runtime', JSON.stringify({
'session-loaded-grok': {
providerId: 'grok-official',
modelId: 'grok-build',
effortLevel: 'max',
},
}))
vi.resetModules()
const { useSessionRuntimeStore: loadedStore } = await import('./sessionRuntimeStore')
expect(loadedStore.getState().selections['session-loaded-grok']).toEqual(
EXPECTED_GROK_SELECTION,
)
expect(JSON.parse(localStorage.getItem('cc-haha-session-runtime')!)).toEqual({
'session-loaded-grok': EXPECTED_GROK_SELECTION,
})
})
})

View File

@ -1,8 +1,20 @@
import { create } from 'zustand'
import type { RuntimeSelection } from '../types/runtime'
import type { SessionListItem } from '../types/session'
import {
GROK_OFFICIAL_DEFAULT_MODEL_ID,
GROK_OFFICIAL_MODELS,
GROK_OFFICIAL_PROVIDER_ID,
} from '../constants/grokOfficialProvider'
const STORAGE_KEY = 'cc-haha-session-runtime'
const RETIRED_GROK_MODEL_IDS = new Set([
'grok-build',
'grok-build-0.1',
'grok-4.3',
'grok-4.20-reasoning',
'grok-4.20-non-reasoning',
])
export const DRAFT_RUNTIME_SELECTION_KEY = '__draft__'
@ -14,13 +26,50 @@ type SessionRuntimeStore = {
syncFromSessions: (sessions: SessionListItem[]) => void
}
function normalizeSelection(selection: RuntimeSelection): RuntimeSelection {
if (
selection.providerId !== GROK_OFFICIAL_PROVIDER_ID ||
!RETIRED_GROK_MODEL_IDS.has(selection.modelId)
) {
return selection
}
const fallback = GROK_OFFICIAL_MODELS.find(
(model) => model.id === GROK_OFFICIAL_DEFAULT_MODEL_ID,
)
return {
providerId: GROK_OFFICIAL_PROVIDER_ID,
modelId: GROK_OFFICIAL_DEFAULT_MODEL_ID,
...(fallback?.defaultReasoningEffort
? { effortLevel: fallback.defaultReasoningEffort }
: {}),
}
}
function normalizeSelections(
selections: Record<string, RuntimeSelection>,
): { selections: Record<string, RuntimeSelection>; changed: boolean } {
let changed = false
const normalized = Object.fromEntries(
Object.entries(selections).map(([key, selection]) => {
const next = normalizeSelection(selection)
if (next !== selection) changed = true
return [key, next]
}),
)
return { selections: normalized, changed }
}
function loadSelections(): Record<string, RuntimeSelection> {
if (typeof localStorage === 'undefined') return {}
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as Record<string, RuntimeSelection>
return parsed && typeof parsed === 'object' ? parsed : {}
if (!parsed || typeof parsed !== 'object') return {}
const normalized = normalizeSelections(parsed)
if (normalized.changed) persistSelections(normalized.selections)
return normalized.selections
} catch {
return {}
}
@ -42,7 +91,7 @@ export const useSessionRuntimeStore = create<SessionRuntimeStore>((set) => ({
set((state) => {
const selections = {
...state.selections,
[key]: selection,
[key]: normalizeSelection(selection),
}
persistSelections(selections)
return { selections }
@ -74,11 +123,11 @@ export const useSessionRuntimeStore = create<SessionRuntimeStore>((set) => ({
let selections = state.selections
for (const session of sessions) {
if (!session.runtimeModelId || session.runtimeProviderId === undefined) continue
const selection: RuntimeSelection = {
const selection = normalizeSelection({
providerId: session.runtimeProviderId,
modelId: session.runtimeModelId,
...(session.effortLevel ? { effortLevel: session.effortLevel } : {}),
}
})
const current = selections[session.id]
if (
current?.providerId === selection.providerId &&

View File

@ -9,7 +9,7 @@ export type ProviderAuthStrategy =
| 'dual_same_token'
| 'dual_dummy'
export type ProviderRuntimeKind = 'anthropic_compatible' | 'openai_oauth'
export type ProviderRuntimeKind = 'anthropic_compatible' | 'openai_oauth' | 'grok_oauth'
export type ModelMapping = {
main: string

View File

@ -750,6 +750,25 @@ describe('ConversationService', () => {
expect(env.ANTHROPIC_BASE_URL).toBeUndefined()
})
test('buildChildEnv injects isolated Grok Official runtime env for session-scoped selection', async () => {
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp', undefined, {
providerId: 'grok-official',
model: 'grok-4.5',
})) as Record<string, string>
expect(env.CC_HAHA_GROK_OAUTH_PROVIDER).toBe('1')
expect(env.GROK_OAUTH_FILE).toBe(path.join(tmpDir, 'cc-haha', 'grok-oauth.json'))
expect(env.ANTHROPIC_MODEL).toBe('grok-4.5')
expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1')
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()
expect(env.CC_HAHA_OPENAI_OAUTH_PROVIDER).toBeUndefined()
expect(env.OPENAI_CODEX_OAUTH_FILE).toBeUndefined()
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
expect(env.ANTHROPIC_BASE_URL).toBeUndefined()
})
test('buildChildEnv passes OpenAI-native effort without leaking Claude effort state', async () => {
const originalEffort = process.env.CC_HAHA_OPENAI_REASONING_EFFORT
process.env.CC_HAHA_OPENAI_REASONING_EFFORT = 'stale-parent-effort'

View File

@ -0,0 +1,61 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'fs/promises'
import * as os from 'os'
import * as path from 'path'
import { handleHahaGrokOAuthApi } from '../api/haha-grok-oauth.js'
import { hahaGrokOAuthService } from '../services/hahaGrokOAuthService.js'
let tempDir: string
let previousConfigDir: string | undefined
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'haha-grok-oauth-api-'))
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = tempDir
})
afterEach(async () => {
hahaGrokOAuthService.dispose()
if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = previousConfigDir
await fs.rm(tempDir, { recursive: true, force: true })
})
describe('Haha Grok OAuth API', () => {
test('serves a clear local success page after browser authorization', async () => {
const response = await handleHahaGrokOAuthApi(
new Request('http://localhost/api/haha-grok-oauth/success'),
new URL('http://localhost/api/haha-grok-oauth/success'),
['api', 'haha-grok-oauth', 'success'],
)
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toContain('text/html')
expect(await response.text()).toContain('Grok Login Successful')
})
test('returns status without exposing token material and logs out', async () => {
await hahaGrokOAuthService.saveTokens({
accessToken: 'secret-access',
refreshToken: 'secret-refresh',
expiresAt: Date.now() + 3600_000,
email: 'user@example.com',
})
const statusResponse = await handleHahaGrokOAuthApi(
new Request('http://localhost/api/haha-grok-oauth'),
new URL('http://localhost/api/haha-grok-oauth'),
['api', 'haha-grok-oauth'],
)
const statusText = await statusResponse.text()
expect(statusText).toContain('user@example.com')
expect(statusText).not.toContain('secret-access')
expect(statusText).not.toContain('secret-refresh')
const logoutResponse = await handleHahaGrokOAuthApi(
new Request('http://localhost/api/haha-grok-oauth', { method: 'DELETE' }),
new URL('http://localhost/api/haha-grok-oauth'),
['api', 'haha-grok-oauth'],
)
expect(logoutResponse.status).toBe(200)
await expect(hahaGrokOAuthService.loadTokens()).resolves.toBeNull()
})
})

View File

@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'fs/promises'
import * as os from 'os'
import * as path from 'path'
import {
HahaGrokOAuthService,
getHahaGrokOAuthFilePath,
} from '../services/hahaGrokOAuthService.js'
let tempDir: string
let previousConfigDir: string | undefined
let previousFetch: typeof globalThis.fetch
let service: HahaGrokOAuthService
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'haha-grok-oauth-test-'))
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = tempDir
previousFetch = globalThis.fetch
service = new HahaGrokOAuthService()
})
afterEach(async () => {
service.dispose()
globalThis.fetch = previousFetch
if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = previousConfigDir
await fs.rm(tempDir, { recursive: true, force: true })
})
describe('HahaGrokOAuthService', () => {
test('starts a random 127.0.0.1 PKCE callback and persists exchanged tokens', async () => {
globalThis.fetch = (async (_input, init) => {
expect(init?.method).toBe('POST')
const body = new URLSearchParams(String(init?.body))
expect(body.get('grant_type')).toBe('authorization_code')
expect(body.get('client_secret')).toBeNull()
return Response.json({
access_token: 'grok-access',
refresh_token: 'grok-refresh',
expires_in: 3600,
})
}) as typeof fetch
const session = await service.startSession()
const authorizeUrl = new URL(session.authorizeUrl)
const redirectUri = new URL(authorizeUrl.searchParams.get('redirect_uri')!)
expect(redirectUri.hostname).toBe('127.0.0.1')
expect(Number(redirectUri.port)).toBeGreaterThan(0)
expect(authorizeUrl.searchParams.get('state')).toBe(session.state)
expect(authorizeUrl.searchParams.get('code_challenge_method')).toBe('S256')
const callback = new URL(session.redirectUri)
callback.searchParams.set('code', 'code')
callback.searchParams.set('state', session.state)
const callbackResponse = await previousFetch(callback)
expect(callbackResponse.status).toBe(200)
expect(await service.loadTokens()).toMatchObject({
accessToken: 'grok-access',
refreshToken: 'grok-refresh',
})
expect((await fs.stat(getHahaGrokOAuthFilePath())).mode & 0o777).toBe(0o600)
})
test('refreshes an expiring token and preserves an unrotated refresh token', async () => {
await service.saveTokens({
accessToken: 'old-access',
refreshToken: 'old-refresh',
expiresAt: Date.now() - 1,
email: 'user@example.com',
})
service.setRefreshFn(async () => ({
access_token: 'new-access',
expires_in: 3600,
}))
await expect(service.ensureFreshTokens()).resolves.toMatchObject({
accessToken: 'new-access',
refreshToken: 'old-refresh',
email: 'user@example.com',
})
await expect(service.loadTokens()).resolves.toMatchObject({
accessToken: 'new-access',
refreshToken: 'old-refresh',
})
})
})

View File

@ -68,7 +68,7 @@ describe('persistent storage upgrade migrations', () => {
}
expect(migrated.schemaVersion).toBe(CURRENT_PROVIDER_INDEX_SCHEMA_VERSION)
expect(migrated.activeId).toBe('provider-1')
expect(migrated.providerOrder).toEqual(['provider-1', 'claude-official', 'openai-official'])
expect(migrated.providerOrder).toEqual(['provider-1', 'claude-official', 'openai-official', 'grok-official'])
expect(migrated.activeProviderId).toBeUndefined()
expect(migrated.rootFutureField).toEqual({ keep: true })
expect(migrated.providers?.[0]?.extraFutureField).toBe('keep-me')
@ -136,7 +136,7 @@ describe('persistent storage upgrade migrations', () => {
}>
}
expect(migrated.activeId).toBe('legacy-provider')
expect(migrated.providerOrder).toEqual(['legacy-provider', 'claude-official', 'openai-official'])
expect(migrated.providerOrder).toEqual(['legacy-provider', 'claude-official', 'openai-official', 'grok-official'])
expect(migrated.providers?.[0]).toMatchObject({
id: 'legacy-provider',
presetId: 'custom',
@ -204,7 +204,7 @@ describe('persistent storage upgrade migrations', () => {
providers?: unknown[]
}
expect(current.activeId).toBeNull()
expect(current.providerOrder).toEqual(['claude-official', 'openai-official'])
expect(current.providerOrder).toEqual(['claude-official', 'openai-official', 'grok-official'])
expect(current.providers).toEqual([])
})

View File

@ -9,6 +9,8 @@ import {
} from '../services/providerRuntimeEnv.js'
let tmpDir: string
let originalConfigDir: string | undefined
let originalHome: string | undefined
async function writeJson(filePath: string, value: unknown): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true })
@ -18,12 +20,52 @@ async function writeJson(filePath: string, value: unknown): Promise<void> {
describe('providerRuntimeEnv', () => {
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'provider-runtime-env-'))
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
originalHome = process.env.HOME
process.env.CLAUDE_CONFIG_DIR = tmpDir
process.env.HOME = tmpDir
})
afterEach(async () => {
if (originalConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalConfigDir
else delete process.env.CLAUDE_CONFIG_DIR
if (originalHome !== undefined) process.env.HOME = originalHome
else delete process.env.HOME
await fs.rm(tmpDir, { recursive: true, force: true })
})
test('normalizes and preserves Grok Official as the active runtime provider', async () => {
await writeJson(path.join(tmpDir, 'cc-haha', 'providers.json'), {
activeId: 'grok-official',
providers: [],
providerOrder: ['claude-official', 'openai-official'],
})
const env = mergeActiveProviderManagedEnv(
{
CC_HAHA_OPENAI_OAUTH_PROVIDER: '1',
OPENAI_CODEX_OAUTH_FILE: path.join(tmpDir, 'stale-openai-oauth.json'),
ANTHROPIC_MODEL: 'stale-openai-model',
DISABLE_AUTOUPDATER: '1',
},
tmpDir,
)
expect(env).toMatchObject({
CC_HAHA_GROK_OAUTH_PROVIDER: '1',
GROK_OAUTH_FILE: path.join(tmpDir, 'cc-haha', 'grok-oauth.json'),
ANTHROPIC_MODEL: 'grok-4.5',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'grok-4.5',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'grok-4.5',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'grok-4.5',
DISABLE_AUTOUPDATER: '1',
})
expect(env.CC_HAHA_OPENAI_OAUTH_PROVIDER).toBeUndefined()
expect(env.OPENAI_CODEX_OAUTH_FILE).toBeUndefined()
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
})
test('derives native Anthropic provider env from the active provider index', async () => {
await writeJson(path.join(tmpDir, 'cc-haha', 'providers.json'), {
activeId: 'provider-1',

View File

@ -16,11 +16,14 @@ import type { CreateProviderInput } from '../types/provider.js'
let tmpDir: string
let originalConfigDir: string | undefined
let originalHome: string | undefined
async function setup() {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'provider-test-'))
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
originalHome = process.env.HOME
process.env.CLAUDE_CONFIG_DIR = tmpDir
process.env.HOME = tmpDir
clearTraceCaptureStateForTests()
}
@ -31,6 +34,11 @@ async function teardown() {
} else {
delete process.env.CLAUDE_CONFIG_DIR
}
if (originalHome !== undefined) {
process.env.HOME = originalHome
} else {
delete process.env.HOME
}
await fs.rm(tmpDir, { recursive: true, force: true })
}
@ -98,7 +106,7 @@ describe('ProviderService', () => {
expect(result).toEqual({
providers: [],
activeId: null,
providerOrder: ['claude-official', 'openai-official'],
providerOrder: ['claude-official', 'openai-official', 'grok-official'],
})
})
@ -113,7 +121,7 @@ describe('ProviderService', () => {
expect(result).toEqual({
providers: [],
activeId: null,
providerOrder: ['claude-official', 'openai-official'],
providerOrder: ['claude-official', 'openai-official', 'grok-official'],
})
expect(files.some((name) => name.startsWith('providers.json.invalid-'))).toBe(true)
})
@ -513,6 +521,104 @@ describe('ProviderService', () => {
})
})
describe('Grok Official provider metadata', () => {
test('normalizes the built-in Grok provider and appends it to legacy provider order', async () => {
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
await fs.writeFile(
path.join(tmpDir, 'cc-haha', 'providers.json'),
JSON.stringify({
activeId: 'grok-official',
providers: [],
providerOrder: ['claude-official', 'openai-official'],
}),
'utf-8',
)
const svc = new ProviderService()
const result = await svc.listProviders()
expect(result.activeId).toBe('grok-official')
expect(result.providers).toEqual([])
expect(result.providerOrder).toEqual([
'claude-official',
'openai-official',
'grok-official',
])
})
test('returns and activates built-in Grok metadata while clearing OpenAI OAuth runtime env', async () => {
const svc = new ProviderService()
const provider = await svc.getProvider('grok-official')
expect(provider).toMatchObject({
id: 'grok-official',
presetId: 'grok-official',
name: 'Grok Official',
apiKey: '',
apiFormat: 'openai_chat',
runtimeKind: 'grok_oauth',
models: {
main: 'grok-4.5',
haiku: 'grok-4.5',
sonnet: 'grok-4.5',
opus: 'grok-4.5',
},
})
await svc.activateProvider('openai-official')
await svc.activateProvider('grok-official')
const config = await readProvidersConfig()
const env = (await readSettings()).env as Record<string, string>
expect(config.activeId).toBe('grok-official')
expect(env.CC_HAHA_GROK_OAUTH_PROVIDER).toBe('1')
expect(env.GROK_OAUTH_FILE).toBe(
path.join(tmpDir, 'cc-haha', 'grok-oauth.json'),
)
expect(env.ANTHROPIC_MODEL).toBe('grok-4.5')
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('grok-4.5')
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('grok-4.5')
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('grok-4.5')
expect(env.CC_HAHA_OPENAI_OAUTH_PROVIDER).toBeUndefined()
expect(env.OPENAI_CODEX_OAUTH_FILE).toBeUndefined()
})
test('auth status reports Grok Official from the isolated Grok token file', async () => {
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
await fs.writeFile(
path.join(tmpDir, 'cc-haha', 'grok-oauth.json'),
JSON.stringify({
accessToken: 'grok-access',
refreshToken: 'grok-refresh',
expiresAt: Date.now() + 60 * 60_000,
email: 'grok@example.com',
clientId: 'grok-client',
}),
'utf-8',
)
const svc = new ProviderService()
await svc.activateProvider('grok-official')
await expect(svc.checkAuthStatus()).resolves.toMatchObject({
hasAuth: true,
source: 'grok-oauth',
activeProvider: 'Grok Official',
})
})
test('auth status reports Grok Official as unauthenticated without an isolated token file', async () => {
const svc = new ProviderService()
await svc.activateProvider('grok-official')
await expect(svc.checkAuthStatus()).resolves.toMatchObject({
hasAuth: false,
source: 'none',
activeProvider: 'Grok Official',
})
})
})
test('should throw 404 for non-existent id', async () => {
const svc = new ProviderService()
@ -716,16 +822,40 @@ describe('ProviderService', () => {
const a = await svc.addProvider(sampleInput({ name: 'A' }))
const b = await svc.addProvider(sampleInput({ name: 'B' }))
const result = await svc.reorderProviders(['openai-official', b.id, 'claude-official', a.id])
const result = await svc.reorderProviders([
'openai-official',
b.id,
'claude-official',
a.id,
'grok-official',
])
expect(result.providerOrder).toEqual(['openai-official', b.id, 'claude-official', a.id])
expect(result.providerOrder).toEqual([
'openai-official',
b.id,
'claude-official',
a.id,
'grok-official',
])
expect(result.providers.map((p) => p.id)).toEqual([b.id, a.id])
const listed = await svc.listProviders()
expect(listed.providerOrder).toEqual(['openai-official', b.id, 'claude-official', a.id])
expect(listed.providerOrder).toEqual([
'openai-official',
b.id,
'claude-official',
a.id,
'grok-official',
])
const config = await readProvidersConfig()
expect(config.providerOrder).toEqual(['openai-official', b.id, 'claude-official', a.id])
expect(config.providerOrder).toEqual([
'openai-official',
b.id,
'claude-official',
a.id,
'grok-official',
])
})
test('should not change activeId when reordering', async () => {
@ -1130,6 +1260,14 @@ describe('ProviderService', () => {
expect(active).toBeNull()
})
test('should return null for explicit Grok Official proxy lookup', async () => {
const svc = new ProviderService()
const active = await svc.getProviderForProxy('grok-official')
expect(active).toBeNull()
})
test('should return the active provider proxy config', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput())
@ -1150,6 +1288,15 @@ describe('ProviderService', () => {
expect(active).toBeNull()
})
test('should return null when Grok Official is the active provider', async () => {
const svc = new ProviderService()
await svc.activateProvider('grok-official')
const active = await svc.getProviderForProxy()
expect(active).toBeNull()
})
})
describe('handleProxyRequest', () => {
@ -1861,7 +2008,13 @@ describe('Providers API', () => {
expect(res.status).toBe(200)
const body = (await res.json()) as { providers: { name: string }[]; providerOrder: string[] }
expect(body.providers.map((p) => p.name)).toEqual(['B', 'A'])
expect(body.providerOrder).toEqual([b.id, a.id, 'claude-official', 'openai-official'])
expect(body.providerOrder).toEqual([
b.id,
a.id,
'claude-official',
'openai-official',
'grok-official',
])
})
test('PUT /api/providers/reorder should return 400 for a non-permutation', async () => {

View File

@ -848,6 +848,35 @@ describe('Models API', () => {
})
})
it('GET /api/models should return the Grok fallback catalog when Grok Official is active', async () => {
const providerSvc = new ProviderService()
await providerSvc.activateProvider('grok-official')
const { req, url, segments } = makeRequest('GET', '/api/models')
const res = await handleModelsApi(req, url, segments)
const body = await res.json() as {
models: Array<{ id: string; context: string }>
provider: { id: string; name: string }
}
expect(body.provider).toEqual({ id: 'grok-official', name: 'Grok Official' })
expect(body.models.map((model) => model.id)).toEqual([
'grok-4.5',
'grok-composer-2.5-fast',
])
expect(body.models.find((model) => model.id === 'grok-4.5')?.context).toBe('500000')
})
it('persists and reads a Grok model from managed settings', async () => {
const providerSvc = new ProviderService()
await providerSvc.activateProvider('grok-official')
const put = makeRequest('PUT', '/api/models/current', { modelId: 'grok-4.5' })
expect((await handleModelsApi(put.req, put.url, put.segments)).status).toBe(200)
const get = makeRequest('GET', '/api/models/current')
const body = await (await handleModelsApi(get.req, get.url, get.segments)).json()
expect(body.model).toMatchObject({ id: 'grok-4.5', name: 'Grok 4.5' })
})
it('GET /api/effort should return default effort level', async () => {
const { req, url, segments } = makeRequest('GET', '/api/effort')
const res = await handleModelsApi(req, url, segments)

View File

@ -0,0 +1,51 @@
import {
GROK_OAUTH_SUCCESS_HTML,
hahaGrokOAuthService,
} from '../services/hahaGrokOAuthService.js'
import { errorResponse } from '../middleware/errorHandler.js'
export async function handleHahaGrokOAuthApi(
req: Request,
_url: URL,
segments: string[],
): Promise<Response> {
try {
const action = segments[2]
if (action === 'start' && req.method === 'POST') {
const session = await hahaGrokOAuthService.startSession()
return Response.json({
authorizeUrl: session.authorizeUrl,
state: session.state,
})
}
if (action === 'success' && req.method === 'GET') {
return new Response(GROK_OAUTH_SUCCESS_HTML, {
headers: {
'Cache-Control': 'no-store',
'Content-Type': 'text/html; charset=utf-8',
},
})
}
if (action === undefined && req.method === 'GET') {
const tokens = await hahaGrokOAuthService.ensureFreshTokens()
if (!tokens) return Response.json({ loggedIn: false })
return Response.json({
loggedIn: true,
expiresAt: tokens.expiresAt,
email: tokens.email,
})
}
if (action === undefined && req.method === 'DELETE') {
hahaGrokOAuthService.dispose()
await hahaGrokOAuthService.deleteTokens()
return Response.json({ ok: true })
}
return Response.json({ error: 'Not Found' }, { status: 404 })
} catch (error) {
return errorResponse(error)
}
}

View File

@ -23,6 +23,17 @@ import {
OPENAI_OFFICIAL_PROVIDER_NAME,
isOpenAIOfficialProviderId,
} from '../services/openaiOfficialProvider.js'
import { getGrokModelCatalog } from '../../services/grokAuth/modelCatalog.js'
import {
GROK_DEFAULT_MAIN_MODEL,
type GrokModelCatalogEntry,
} from '../../services/grokAuth/models.js'
import {
GROK_OFFICIAL_PROVIDER_ID,
GROK_OFFICIAL_PROVIDER_NAME,
isGrokOfficialProviderId,
} from '../services/grokOfficialProvider.js'
import { hahaGrokOAuthService } from '../services/hahaGrokOAuthService.js'
// ─── Fallback models (used when no provider is configured) ────────────────────
@ -136,6 +147,29 @@ async function getOpenAIModelList(): Promise<ApiModelInfo[]> {
return buildOpenAIModelList(await getOpenAICodexModelCatalog())
}
function buildGrokModelList(catalog: GrokModelCatalogEntry[]): ApiModelInfo[] {
return catalog.map((model) => ({
id: model.value,
name: model.label,
description: model.description,
context: model.contextWindow ? String(model.contextWindow) : '',
...(model.reasoningEffort && { defaultReasoningEffort: model.reasoningEffort }),
...(model.supportsReasoningEffort === false
? { supportedReasoningEfforts: [] }
: model.reasoningEfforts
? { supportedReasoningEfforts: model.reasoningEfforts }
: {}),
}))
}
async function getGrokModelList(): Promise<ApiModelInfo[]> {
const tokens = await hahaGrokOAuthService.ensureFreshTokens()
return buildGrokModelList(await getGrokModelCatalog({
...(tokens?.accessToken ? { accessToken: tokens.accessToken } : {}),
accountKey: tokens?.email ?? (tokens ? 'authenticated-default' : 'logged-out'),
}))
}
function getEnvConfiguredAnthropicModels(): ApiModelInfo[] {
return buildProviderModelList({
main: process.env.ANTHROPIC_MODEL?.trim() || '',
@ -220,6 +254,15 @@ async function handleModelsList(): Promise<Response> {
},
})
}
if (isGrokOfficialProviderId(activeId)) {
return Response.json({
models: await getGrokModelList(),
provider: {
id: GROK_OFFICIAL_PROVIDER_ID,
name: GROK_OFFICIAL_PROVIDER_NAME,
},
})
}
const activeProvider = activeId ? providers.find((p) => p.id === activeId) : null
if (activeProvider) {
@ -237,8 +280,9 @@ async function handleCurrentModel(req: Request): Promise<Response> {
// Build the full model list: prefer active provider's models, fall back to defaults
const { providers, activeId } = await providerService.listProviders()
const isOpenAIProviderActive = isOpenAIOfficialProviderId(activeId)
const isGrokProviderActive = isGrokOfficialProviderId(activeId)
const activeProvider = activeId ? providers.find((p) => p.id === activeId) : null
const settings = activeProvider || isOpenAIProviderActive
const settings = activeProvider || isOpenAIProviderActive || isGrokProviderActive
? await providerService.getManagedSettings()
: await settingsService.getUserSettings()
const explicitModel = (settings.model as string) || ''
@ -252,6 +296,9 @@ async function handleCurrentModel(req: Request): Promise<Response> {
if (isOpenAIProviderActive) {
currentModelId = explicitModel || env.ANTHROPIC_MODEL || OPENAI_DEFAULT_MAIN_MODEL
currentModelName = currentModelId
} else if (isGrokProviderActive) {
currentModelId = explicitModel || env.ANTHROPIC_MODEL || GROK_DEFAULT_MAIN_MODEL
currentModelName = currentModelId
} else if (activeProvider) {
// Provider is active — only use the provider-managed cc-haha settings.
// This avoids leaking global ~/.claude/settings.json model choices into
@ -275,9 +322,11 @@ async function handleCurrentModel(req: Request): Promise<Response> {
// Build available models for name lookup
const availableModels = isOpenAIProviderActive
? await getOpenAIModelList()
: activeProvider
? buildProviderModelList(activeProvider.models)
: await getStandaloneModelList()
: isGrokProviderActive
? await getGrokModelList()
: activeProvider
? buildProviderModelList(activeProvider.models)
: await getStandaloneModelList()
const modelEntry = availableModels.find((m) => m.id === lookupId)
|| availableModels.find((m) => m.id === currentModelId)

View File

@ -19,6 +19,7 @@ import { handleMarketApi } from './api/market.js'
import { handleComputerUseApi } from './api/computer-use.js'
import { handleHahaOAuthApi } from './api/haha-oauth.js'
import { handleHahaOpenAIOAuthApi } from './api/haha-openai-oauth.js'
import { handleHahaGrokOAuthApi } from './api/haha-grok-oauth.js'
import { handleMcpApi } from './api/mcp.js'
import { handleDiagnosticsApi } from './api/diagnostics.js'
import { handleDoctorApi } from './api/doctor.js'
@ -84,6 +85,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
case 'haha-openai-oauth':
return handleHahaOpenAIOAuthApi(req, url, segments)
case 'haha-grok-oauth':
return handleHahaGrokOAuthApi(req, url, segments)
case 'adapters':
// Adapter protocols pull in platform SDKs that are unnecessary for the
// core server path. Load them only when this API is actually used.

View File

@ -15,6 +15,10 @@ import {
OPENAI_OAUTH_PROVIDER_ENV_KEY,
isOpenAIOfficialProviderId,
} from './openaiOfficialProvider.js'
import {
GROK_OAUTH_FILE_ENV_KEY,
GROK_OAUTH_PROVIDER_ENV_KEY,
} from './grokOfficialProvider.js'
import {
OPENAI_CODEX_REASONING_EFFORT_ENV_KEY,
isOpenAIReasoningEffort,
@ -1167,6 +1171,8 @@ export class ConversationService {
OPENAI_OAUTH_PROVIDER_ENV_KEY,
OPENAI_CODEX_OAUTH_FILE_ENV_KEY,
OPENAI_CODEX_REASONING_EFFORT_ENV_KEY,
GROK_OAUTH_PROVIDER_ENV_KEY,
GROK_OAUTH_FILE_ENV_KEY,
] as const
const cleanEnv = await getProcessEnvWithTerminalShellEnvironment()
@ -1414,6 +1420,8 @@ export class ConversationService {
'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS',
OPENAI_OAUTH_PROVIDER_ENV_KEY,
OPENAI_CODEX_OAUTH_FILE_ENV_KEY,
GROK_OAUTH_PROVIDER_ENV_KEY,
GROK_OAUTH_FILE_ENV_KEY,
].some((key) => typeof env[key] === 'string' && env[key]!.trim().length > 0)
} catch {
return false
@ -1447,6 +1455,9 @@ export class ConversationService {
if (env[OPENAI_OAUTH_PROVIDER_ENV_KEY] === '1') {
return false
}
if (env[GROK_OAUTH_PROVIDER_ENV_KEY] === '1') {
return false
}
const hasProviderEnv = [
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',

View File

@ -201,6 +201,12 @@ export class DoctorService {
'user',
path.join(this.configDir, 'cc-haha', 'openai-oauth.json'),
),
this.jsonTarget(
'grok-oauth',
'Grok OAuth tokens',
'user',
path.join(this.configDir, 'cc-haha', 'grok-oauth.json'),
),
]
if (this.projectRoot) {

View File

@ -0,0 +1,58 @@
import {
GROK_DEFAULT_HAIKU_MODEL,
GROK_DEFAULT_MAIN_MODEL,
GROK_DEFAULT_SONNET_MODEL,
GROK_MODEL_CATALOG,
getGrokContextWindowForModel,
} from '../../services/grokAuth/models.js'
import { GROK_OAUTH_FILE_ENV_KEY } from '../../services/grokAuth/storage.js'
import { MODEL_CONTEXT_WINDOWS_ENV_KEY } from '../../utils/model/modelContextWindows.js'
import {
GROK_OFFICIAL_PROVIDER_ID,
type SavedProvider,
} from '../types/provider.js'
import { getHahaGrokOAuthFilePath } from './hahaGrokOAuthService.js'
export { GROK_OFFICIAL_PROVIDER_ID, GROK_OAUTH_FILE_ENV_KEY }
export const GROK_OFFICIAL_PROVIDER_NAME = 'Grok Official'
export const GROK_OAUTH_PROVIDER_ENV_KEY = 'CC_HAHA_GROK_OAUTH_PROVIDER'
export function isGrokOfficialProviderId(id: string | null | undefined): boolean {
return id === GROK_OFFICIAL_PROVIDER_ID
}
const modelContextWindows = Object.fromEntries(
GROK_MODEL_CATALOG
.map(({ value }) => [value, getGrokContextWindowForModel(value)] as const)
.filter((entry): entry is readonly [string, number] => entry[1] !== null),
)
export const GROK_OFFICIAL_PROVIDER: SavedProvider = {
id: GROK_OFFICIAL_PROVIDER_ID,
presetId: GROK_OFFICIAL_PROVIDER_ID,
name: GROK_OFFICIAL_PROVIDER_NAME,
apiKey: '',
authStrategy: 'dual_dummy',
baseUrl: 'https://cli-chat-proxy.grok.com/v1',
apiFormat: 'openai_chat',
runtimeKind: 'grok_oauth',
models: {
main: GROK_DEFAULT_MAIN_MODEL,
haiku: GROK_DEFAULT_HAIKU_MODEL,
sonnet: GROK_DEFAULT_SONNET_MODEL,
opus: GROK_DEFAULT_MAIN_MODEL,
},
modelContextWindows,
}
export function buildGrokOfficialRuntimeEnv(): Record<string, string> {
return {
[GROK_OAUTH_PROVIDER_ENV_KEY]: '1',
[GROK_OAUTH_FILE_ENV_KEY]: getHahaGrokOAuthFilePath(),
[MODEL_CONTEXT_WINDOWS_ENV_KEY]: JSON.stringify(modelContextWindows),
ANTHROPIC_MODEL: GROK_DEFAULT_MAIN_MODEL,
ANTHROPIC_DEFAULT_HAIKU_MODEL: GROK_DEFAULT_HAIKU_MODEL,
ANTHROPIC_DEFAULT_SONNET_MODEL: GROK_DEFAULT_SONNET_MODEL,
ANTHROPIC_DEFAULT_OPUS_MODEL: GROK_DEFAULT_MAIN_MODEL,
}
}

View File

@ -0,0 +1,248 @@
import * as fs from 'fs/promises'
import * as os from 'os'
import * as path from 'path'
import { AuthCodeListener } from '../../services/oauth/auth-code-listener.js'
import {
buildGrokAuthorizeUrl,
exchangeGrokCodeForTokens,
generateGrokCodeVerifier,
generateGrokNonce,
generateGrokState,
isGrokTokenExpired,
normalizeGrokTokens,
refreshGrokTokens,
withRefreshedGrokAccessToken,
type GrokTokenFetchOptions,
} from '../../services/grokAuth/client.js'
import type { GrokOAuthTokenResponse } from '../../services/grokAuth/types.js'
import { logTokenRefreshFailure } from './oauthRefreshLog.js'
import {
getManualNetworkProxyUrl,
loadNetworkSettings,
} from './networkSettings.js'
export type StoredGrokOAuthTokens = {
accessToken: string
refreshToken: string | null
expiresAt: number | null
idToken?: string | null
email: string | null
clientId?: string | null
}
export type GrokOAuthSession = {
state: string
codeVerifier: string
authorizeUrl: string
redirectUri: string
createdAt: number
authCodeListener?: AuthCodeListener
expiresTimer?: ReturnType<typeof setTimeout>
}
type GrokRefreshFn = (
refreshToken: string,
options?: GrokTokenFetchOptions,
) => Promise<GrokOAuthTokenResponse>
const SESSION_TTL_MS = 10 * 60 * 1000
const CALLBACK_PATH = '/callback'
export const GROK_OAUTH_SUCCESS_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>Grok Login Success</title>
<style>body{font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#fafafa;color:#333}.card{text-align:center;padding:40px;background:white;border-radius:12px;box-shadow:0 4px 16px rgba(0,0,0,.06)}h1{color:#16a34a;margin:0 0 12px}p{color:#666}</style>
</head><body><div class="card"><h1> Grok Login Successful</h1><p>Authorization is complete. You can close this window and return to Claude Code Haha.</p></div><script>setTimeout(() => window.close(), 3000)</script></body></html>`
function renderErrorHtml(message: string): string {
return `<!doctype html><html><head><meta charset="utf-8"><title>Grok Login Failed</title>
<style>body{font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#fafafa;color:#333}.card{text-align:center;padding:40px;background:white;border-radius:12px;box-shadow:0 4px 16px rgba(0,0,0,.06)}h1{color:#dc2626;margin:0 0 12px}pre{color:#666;white-space:pre-wrap;word-break:break-word;text-align:left;background:#f5f5f5;padding:12px;border-radius:6px}</style>
</head><body><div class="card"><h1>Grok Login Failed</h1><pre>${escapeHtml(message)}</pre></div></body></html>`
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
export function getHahaGrokOAuthFilePath(): string {
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
return path.join(configDir, 'cc-haha', 'grok-oauth.json')
}
export class HahaGrokOAuthService {
private sessions = new Map<string, GrokOAuthSession>()
private refreshFn: GrokRefreshFn = refreshGrokTokens
setRefreshFn(fn: GrokRefreshFn): void {
this.refreshFn = fn
}
getOAuthFilePath(): string {
return getHahaGrokOAuthFilePath()
}
async loadTokens(): Promise<StoredGrokOAuthTokens | null> {
try {
return JSON.parse(await fs.readFile(this.getOAuthFilePath(), 'utf-8')) as StoredGrokOAuthTokens
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
throw error
}
}
async saveTokens(tokens: StoredGrokOAuthTokens): Promise<void> {
const filePath = this.getOAuthFilePath()
await fs.mkdir(path.dirname(filePath), { recursive: true })
const temporaryPath = `${filePath}.tmp.${process.pid}.${Date.now()}`
let renamed = false
try {
await fs.writeFile(temporaryPath, `${JSON.stringify(tokens, null, 2)}\n`, { mode: 0o600 })
await fs.rename(temporaryPath, filePath)
renamed = true
} finally {
if (!renamed) await fs.rm(temporaryPath, { force: true }).catch(() => {})
}
}
async deleteTokens(): Promise<void> {
await fs.rm(this.getOAuthFilePath(), { force: true })
}
async startSession(): Promise<GrokOAuthSession> {
this.dispose()
const codeVerifier = generateGrokCodeVerifier()
const state = generateGrokState()
const nonce = generateGrokNonce()
const authCodeListener = new AuthCodeListener(CALLBACK_PATH)
const port = await authCodeListener.start(undefined, '127.0.0.1')
const redirectUri = `http://127.0.0.1:${port}${CALLBACK_PATH}`
const authorizeUrl = buildGrokAuthorizeUrl({ redirectUri, codeVerifier, state, nonce })
const session: GrokOAuthSession = {
state,
codeVerifier,
authorizeUrl,
redirectUri,
createdAt: Date.now(),
authCodeListener,
}
session.expiresTimer = setTimeout(() => {
if (this.sessions.get(state) === session) {
this.closeSession(session)
this.sessions.delete(state)
}
}, SESSION_TTL_MS)
session.expiresTimer.unref?.()
this.sessions.set(state, session)
this.waitForDesktopCallback(session)
return session
}
private waitForDesktopCallback(session: GrokOAuthSession): void {
const listener = session.authCodeListener
if (!listener) return
void listener.waitForAuthorization(session.state, async () => {})
.then(async (code) => {
try {
await this.completeSession(code, session.state)
listener.handleSuccessRedirect([], (response) => {
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
response.end(GROK_OAUTH_SUCCESS_HTML)
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
listener.handleSuccessRedirect([], (response) => {
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
response.end(renderErrorHtml(message))
})
} finally {
this.closeSession(session)
this.sessions.delete(session.state)
}
})
.catch(() => {
this.closeSession(session)
this.sessions.delete(session.state)
})
}
async completeSession(code: string, state: string): Promise<StoredGrokOAuthTokens> {
const session = this.sessions.get(state)
if (!session || Date.now() - session.createdAt > SESSION_TTL_MS) {
throw new Error('Grok OAuth session not found or expired')
}
this.sessions.delete(state)
const response = await exchangeGrokCodeForTokens({
code,
redirectUri: session.redirectUri,
codeVerifier: session.codeVerifier,
...(await this.getTokenFetchOptions()),
})
const normalized = normalizeGrokTokens(response)
const tokens: StoredGrokOAuthTokens = {
accessToken: normalized.accessToken,
refreshToken: normalized.refreshToken,
expiresAt: normalized.expiresAt,
idToken: normalized.idToken ?? null,
email: normalized.email ?? null,
clientId: normalized.clientId ?? null,
}
await this.saveTokens(tokens)
return tokens
}
async ensureFreshTokens(): Promise<StoredGrokOAuthTokens | null> {
const tokens = await this.loadTokens()
if (!tokens) return null
if (tokens.expiresAt === null || !isGrokTokenExpired(tokens.expiresAt)) return tokens
if (!tokens.refreshToken) return null
try {
const response = await this.refreshFn(tokens.refreshToken, await this.getTokenFetchOptions())
const normalized = withRefreshedGrokAccessToken({
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
...(tokens.idToken ? { idToken: tokens.idToken } : {}),
...(tokens.email ? { email: tokens.email } : {}),
...(tokens.clientId ? { clientId: tokens.clientId } : {}),
}, response)
const updated: StoredGrokOAuthTokens = {
accessToken: normalized.accessToken,
refreshToken: normalized.refreshToken,
expiresAt: normalized.expiresAt,
idToken: normalized.idToken ?? null,
email: normalized.email ?? null,
clientId: normalized.clientId ?? null,
}
await this.saveTokens(updated)
return updated
} catch (error) {
logTokenRefreshFailure('[HahaGrokOAuthService]', error)
return null
}
}
dispose(): void {
for (const session of this.sessions.values()) this.closeSession(session)
this.sessions.clear()
}
private closeSession(session: GrokOAuthSession): void {
if (session.expiresTimer) clearTimeout(session.expiresTimer)
session.expiresTimer = undefined
session.authCodeListener?.close()
session.authCodeListener = undefined
}
private async getTokenFetchOptions(): Promise<GrokTokenFetchOptions> {
const settings = await loadNetworkSettings()
return {
proxyUrl: getManualNetworkProxyUrl(settings),
timeoutMs: settings.aiRequestTimeoutMs,
}
}
}
export const hahaGrokOAuthService = new HahaGrokOAuthService()

View File

@ -4,6 +4,7 @@ import * as path from 'path'
import { randomBytes } from 'node:crypto'
import { normalizeLegacyDeepSeekManagedEnv } from '../../utils/providerManagedEnvCompat.js'
import { isOpenAIOfficialProviderId } from './openaiOfficialProvider.js'
import { isGrokOfficialProviderId } from './grokOfficialProvider.js'
import { BUILT_IN_PROVIDER_IDS } from '../types/provider.js'
export const CURRENT_PROVIDER_INDEX_SCHEMA_VERSION = 2
@ -175,7 +176,8 @@ function migrateProvidersIndex(value: unknown): JsonObject {
: null
const activeId = rawActiveId && (
providers.some((provider) => provider.id === rawActiveId) ||
isOpenAIOfficialProviderId(rawActiveId)
isOpenAIOfficialProviderId(rawActiveId) ||
isGrokOfficialProviderId(rawActiveId)
)
? rawActiveId
: null

View File

@ -22,6 +22,12 @@ import {
buildOpenAIOfficialRuntimeEnv,
isOpenAIOfficialProviderId,
} from './openaiOfficialProvider.js'
import {
GROK_OAUTH_FILE_ENV_KEY,
GROK_OAUTH_PROVIDER_ENV_KEY,
buildGrokOfficialRuntimeEnv,
isGrokOfficialProviderId,
} from './grokOfficialProvider.js'
export const MANAGED_PROVIDER_ENV_KEYS = [
'ANTHROPIC_BASE_URL',
@ -41,6 +47,8 @@ export const MANAGED_PROVIDER_ENV_KEYS = [
MODEL_CONTEXT_WINDOWS_ENV_KEY,
OPENAI_OAUTH_PROVIDER_ENV_KEY,
OPENAI_CODEX_OAUTH_FILE_ENV_KEY,
GROK_OAUTH_PROVIDER_ENV_KEY,
GROK_OAUTH_FILE_ENV_KEY,
] as const
const CUSTOM_PROVIDER_MODEL_CAPABILITIES = 'thinking,effort,adaptive_thinking,max_effort'
@ -81,7 +89,8 @@ function isSavedProvider(value: unknown): value is SavedProvider {
(
runtimeKind === undefined ||
runtimeKind === 'anthropic_compatible' ||
runtimeKind === 'openai_oauth'
runtimeKind === 'openai_oauth' ||
runtimeKind === 'grok_oauth'
) &&
isProviderModels(value.models) &&
(value.model1mSupport === undefined || isProviderModel1mSupport(value.model1mSupport))
@ -227,7 +236,8 @@ export function normalizeProvidersIndex(value: unknown): ProvidersIndex | null {
: null
const activeId = rawActiveId && (
providers.some((provider) => provider.id === rawActiveId) ||
isOpenAIOfficialProviderId(rawActiveId)
isOpenAIOfficialProviderId(rawActiveId) ||
isGrokOfficialProviderId(rawActiveId)
)
? rawActiveId
: null
@ -323,6 +333,9 @@ export function buildProviderManagedEnv(
if (provider.runtimeKind === 'openai_oauth') {
return buildOpenAIOfficialRuntimeEnv()
}
if (provider.runtimeKind === 'grok_oauth') {
return buildGrokOfficialRuntimeEnv()
}
const apiFormat: ApiFormat = provider.apiFormat ?? 'anthropic'
const needsProxy = apiFormat !== 'anthropic'
@ -387,6 +400,9 @@ export function readActiveProviderManagedEnv(
if (isOpenAIOfficialProviderId(index.activeId)) {
return buildOpenAIOfficialRuntimeEnv()
}
if (isGrokOfficialProviderId(index.activeId)) {
return buildGrokOfficialRuntimeEnv()
}
const provider = index.providers.find((entry) => entry.id === index.activeId)
if (!provider) return null
@ -403,7 +419,11 @@ export function activeProviderNeedsProxy(configDir: string): boolean {
try {
const raw = fs.readFileSync(path.join(configDir, 'cc-haha', 'providers.json'), 'utf-8')
const index = normalizeProvidersIndex(JSON.parse(raw))
if (!index?.activeId || isOpenAIOfficialProviderId(index.activeId)) {
if (
!index?.activeId ||
isOpenAIOfficialProviderId(index.activeId) ||
isGrokOfficialProviderId(index.activeId)
) {
return false
}

View File

@ -22,6 +22,11 @@ import {
isOpenAIOfficialProviderId,
} from './openaiOfficialProvider.js'
import { hahaOpenAIOAuthService } from './hahaOpenAIOAuthService.js'
import {
GROK_OFFICIAL_PROVIDER,
isGrokOfficialProviderId,
} from './grokOfficialProvider.js'
import { hahaGrokOAuthService } from './hahaGrokOAuthService.js'
import {
CURRENT_PROVIDER_INDEX_SCHEMA_VERSION,
ensurePersistentStorageUpgraded,
@ -202,6 +207,9 @@ export class ProviderService {
if (isOpenAIOfficialProviderId(id)) {
return OPENAI_OFFICIAL_PROVIDER
}
if (isGrokOfficialProviderId(id)) {
return GROK_OFFICIAL_PROVIDER
}
const index = await this.readIndex()
const provider = index.providers.find((p) => p.id === id)
@ -332,13 +340,15 @@ export class ProviderService {
const index = await this.readIndex()
const provider = isOpenAIOfficialProviderId(id)
? OPENAI_OFFICIAL_PROVIDER
: index.providers.find((p) => p.id === id)
: isGrokOfficialProviderId(id)
? GROK_OFFICIAL_PROVIDER
: index.providers.find((p) => p.id === id)
if (!provider) throw ApiError.notFound(`Provider not found: ${id}`)
index.activeId = id
await this.writeIndex(index)
if (provider.runtimeKind === 'openai_oauth') {
if (provider.runtimeKind === 'openai_oauth' || provider.runtimeKind === 'grok_oauth') {
await this.syncToSettings(provider)
} else if (provider.presetId === 'official') {
await this.clearProviderFromSettings()
@ -431,7 +441,7 @@ export class ProviderService {
*/
async checkAuthStatus(): Promise<{
hasAuth: boolean
source: 'cc-haha-provider' | 'openai-oauth' | 'original-settings' | 'env' | 'none'
source: 'cc-haha-provider' | 'openai-oauth' | 'grok-oauth' | 'original-settings' | 'env' | 'none'
activeProvider?: string
}> {
// 1. Check cc-haha active provider
@ -452,6 +462,21 @@ export class ProviderService {
activeProvider: OPENAI_OFFICIAL_PROVIDER.name,
}
}
if (isGrokOfficialProviderId(index.activeId)) {
const tokens = await hahaGrokOAuthService.ensureFreshTokens()
if (tokens?.accessToken && tokens.refreshToken) {
return {
hasAuth: true,
source: 'grok-oauth',
activeProvider: GROK_OFFICIAL_PROVIDER.name,
}
}
return {
hasAuth: false,
source: 'none',
activeProvider: GROK_OFFICIAL_PROVIDER.name,
}
}
const provider = index.providers.find(p => p.id === index.activeId)
if (provider) {
@ -495,7 +520,7 @@ export class ProviderService {
apiFormat: ApiFormat
} | null> {
if (providerId) {
if (isOpenAIOfficialProviderId(providerId)) {
if (isOpenAIOfficialProviderId(providerId) || isGrokOfficialProviderId(providerId)) {
return null
}
const provider = await this.getProvider(providerId)
@ -510,7 +535,7 @@ export class ProviderService {
const index = await this.readIndex()
if (!index.activeId) return null
if (isOpenAIOfficialProviderId(index.activeId)) {
if (isOpenAIOfficialProviderId(index.activeId) || isGrokOfficialProviderId(index.activeId)) {
return null
}
const provider = await this.getProvider(index.activeId).catch(() => null)

View File

@ -9,11 +9,17 @@ import { z } from 'zod'
export const CLAUDE_OFFICIAL_PROVIDER_ID = 'claude-official'
export const OPENAI_OFFICIAL_PROVIDER_ID = 'openai-official'
export const GROK_OFFICIAL_PROVIDER_ID = 'grok-official'
export const BUILT_IN_PROVIDER_IDS = [
CLAUDE_OFFICIAL_PROVIDER_ID,
OPENAI_OFFICIAL_PROVIDER_ID,
GROK_OFFICIAL_PROVIDER_ID,
] as const
export function isBuiltInProviderId(id: string | null | undefined): boolean {
return !!id && (BUILT_IN_PROVIDER_IDS as readonly string[]).includes(id)
}
export const ApiFormatSchema = z.enum([
'anthropic', // Native Anthropic Messages API (passthrough, no proxy)
'openai_chat', // OpenAI Chat Completions /v1/chat/completions
@ -33,6 +39,7 @@ export type ProviderAuthStrategy = z.infer<typeof ProviderAuthStrategySchema>
export const ProviderRuntimeKindSchema = z.enum([
'anthropic_compatible',
'openai_oauth',
'grok_oauth',
])
export type ProviderRuntimeKind = z.infer<typeof ProviderRuntimeKindSchema>

View File

@ -24,12 +24,16 @@ import { sessionService } from '../services/sessionService.js'
import { SettingsService } from '../services/settingsService.js'
import { ProviderService } from '../services/providerService.js'
import { isOpenAIOfficialProviderId } from '../services/openaiOfficialProvider.js'
import { isGrokOfficialProviderId } from '../services/grokOfficialProvider.js'
import { getOpenAICodexModelCatalog } from '../../services/openaiAuth/modelCatalog.js'
import {
OPENAI_DEFAULT_MAIN_MODEL,
getOpenAIModelCatalogEntry,
isOpenAIReasoningEffort,
} from '../../services/openaiAuth/models.js'
import { GROK_DEFAULT_MAIN_MODEL } from '../../services/grokAuth/models.js'
import { getGrokModelCatalog } from '../../services/grokAuth/modelCatalog.js'
import { hahaGrokOAuthService } from '../services/hahaGrokOAuthService.js'
import { diagnosticsService } from '../services/diagnosticsService.js'
import {
buildConversationTitleInput,
@ -822,7 +826,7 @@ async function handleSetRuntimeConfig(
message: Extract<ClientMessage, { type: 'set_runtime_config' }>
) {
const { sessionId } = ws.data
const modelId = typeof message.modelId === 'string' ? message.modelId.trim() : ''
let modelId = typeof message.modelId === 'string' ? message.modelId.trim() : ''
if (!modelId) {
sendMessage(ws, {
type: 'error',
@ -831,6 +835,9 @@ async function handleSetRuntimeConfig(
})
return
}
if (isGrokOfficialProviderId(message.providerId)) {
modelId = (await getGrokReasoningEfforts(modelId)).modelId
}
const effortLevel =
typeof message.effortLevel === 'string' ? message.effortLevel.trim() : undefined
if (
@ -2715,11 +2722,35 @@ async function getDefaultOpenAIReasoningEffort(modelId: string): Promise<string>
return getOpenAIModelCatalogEntry(modelId, catalog)?.defaultReasoningEffort ?? 'medium'
}
async function getGrokReasoningEfforts(modelId: string): Promise<{
modelId: string
defaultEffort?: string
supportedEfforts: string[]
}> {
const tokens = await hahaGrokOAuthService.ensureFreshTokens()
const catalog = await getGrokModelCatalog({
...(tokens?.accessToken ? { accessToken: tokens.accessToken } : {}),
accountKey: tokens?.email ?? (tokens ? 'authenticated-default' : 'logged-out'),
})
const model = catalog.find((entry) => entry.value === modelId)
?? catalog.find((entry) => entry.value === GROK_DEFAULT_MAIN_MODEL)
?? catalog[0]
return {
modelId: model?.value ?? GROK_DEFAULT_MAIN_MODEL,
...(model?.reasoningEffort ? { defaultEffort: model.reasoningEffort } : {}),
supportedEfforts: model?.reasoningEfforts ?? [],
}
}
async function isRuntimeEffortSupported(
providerId: string | null | undefined,
modelId: string,
effort: string,
): Promise<boolean> {
if (isGrokOfficialProviderId(providerId)) {
const { supportedEfforts } = await getGrokReasoningEfforts(modelId)
return supportedEfforts.includes(effort)
}
if (!isOpenAIOfficialProviderId(providerId)) {
return VALID_CLAUDE_EFFORT_LEVELS.has(effort)
}
@ -2738,6 +2769,7 @@ function isKnownRuntimeProviderId(
): boolean {
return (
isOpenAIOfficialProviderId(providerId) ||
isGrokOfficialProviderId(providerId) ||
providers.some((provider) => provider.id === providerId)
)
}
@ -2782,11 +2814,16 @@ async function getRuntimeSettings(sessionId?: string): Promise<RuntimeSettings>
userSettings,
runtimeOverride.providerId,
)
const effort = runtimeOverride.effort ?? (
isOpenAIOfficialProviderId(runtimeOverride.providerId)
? await getDefaultOpenAIReasoningEffort(runtimeOverride.modelId)
: undefined
)
let effort = runtimeOverride.effort
if (isOpenAIOfficialProviderId(runtimeOverride.providerId)) {
effort = effort ?? await getDefaultOpenAIReasoningEffort(runtimeOverride.modelId)
} else if (isGrokOfficialProviderId(runtimeOverride.providerId)) {
const grokEffort = await getGrokReasoningEfforts(runtimeOverride.modelId)
runtimeOverride.modelId = grokEffort.modelId
effort = effort && grokEffort.supportedEfforts.includes(effort)
? effort
: grokEffort.defaultEffort
}
return {
permissionMode: sessionPermissionMode ?? await settingsService.getPermissionMode().catch(() => undefined),
@ -2850,6 +2887,9 @@ async function getDefaultRuntimeSettings(): Promise<RuntimeSettings> {
if (isOpenAIOfficialProviderId(resolvedActiveId)) {
model = model || OPENAI_DEFAULT_MAIN_MODEL
effort = await getDefaultOpenAIReasoningEffort(model)
} else if (isGrokOfficialProviderId(resolvedActiveId)) {
model = model || GROK_DEFAULT_MAIN_MODEL
effort = (await getGrokReasoningEfforts(model)).defaultEffort
}
} else {
// No provider — pass model normally

View File

@ -1,4 +1,8 @@
import { describe, expect, mock, test } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { GROK_OAUTH_DUMMY_KEY } from '../grokAuth/fetch.js'
mock.module('src/utils/http.js', () => ({
getAuthHeaders: mock(() => ({})),
@ -83,6 +87,39 @@ describe('shouldUseOpenAICodexTransport', () => {
})
describe('getAnthropicClient', () => {
test('selects the isolated Grok transport with a dummy SDK key', async () => {
const { getAnthropicClient } = await import('./client.js')
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grok-client-test-'))
const tokenFile = path.join(tempDir, 'grok-oauth.json')
await fs.writeFile(tokenFile, JSON.stringify({
accessToken: 'grok-access',
refreshToken: 'grok-refresh',
expiresAt: Date.now() + 3600_000,
}))
const previous = {
marker: process.env.CC_HAHA_GROK_OAUTH_PROVIDER,
tokenFile: process.env.GROK_OAUTH_FILE,
configDir: process.env.CLAUDE_CONFIG_DIR,
}
process.env.CC_HAHA_GROK_OAUTH_PROVIDER = '1'
process.env.GROK_OAUTH_FILE = tokenFile
process.env.CLAUDE_CONFIG_DIR = tempDir
try {
const client = await getAnthropicClient({ maxRetries: 0, model: 'grok-4.5' })
expect(client.apiKey).toBe(GROK_OAUTH_DUMMY_KEY)
expect(client.authToken).toBeNull()
expect(client._options.fetch).toBeFunction()
} finally {
if (previous.marker === undefined) delete process.env.CC_HAHA_GROK_OAUTH_PROVIDER
else process.env.CC_HAHA_GROK_OAUTH_PROVIDER = previous.marker
if (previous.tokenFile === undefined) delete process.env.GROK_OAUTH_FILE
else process.env.GROK_OAUTH_FILE = previous.tokenFile
if (previous.configDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = previous.configDir
await fs.rm(tempDir, { recursive: true, force: true })
}
})
test('passes bearer-token provider auth without an SDK api key', async () => {
const { getAnthropicClient } = await import('./client.js')
const originalAuthToken = process.env.ANTHROPIC_AUTH_TOKEN

View File

@ -29,6 +29,11 @@ import {
shouldUseOpenAICodexAuth,
} from '../openaiAuth/fetch.js'
import { isOpenAIResponsesModel } from '../openaiAuth/models.js'
import {
buildGrokFetch,
GROK_OAUTH_DUMMY_KEY,
shouldUseGrokAuth,
} from '../grokAuth/fetch.js'
import { isDebugToStdErr, logForDebugging } from '../../utils/debug.js'
import {
getAWSRegion,
@ -183,9 +188,11 @@ export async function getAnthropicClient({
logForDebugging('[API:auth] OAuth token check complete')
const isOpenAIModel = model ? isOpenAIResponsesModel(model) : false
const isClaudeSubscriber = isClaudeAISubscriber()
const forceOpenAICodex = isEnvTruthy(process.env.CC_HAHA_OPENAI_OAUTH_PROVIDER)
const forceGrok = isEnvTruthy(process.env.CC_HAHA_GROK_OAUTH_PROVIDER)
const isClaudeSubscriber = forceGrok ? false : isClaudeAISubscriber()
const hasOpenAIAuth = shouldUseOpenAICodexAuth()
const usingGrok = forceGrok && shouldUseGrokAuth()
const hasFallbackApiKey = hasOpenAIAuth &&
!process.env.ANTHROPIC_AUTH_TOKEN &&
!apiKey &&
@ -200,13 +207,15 @@ export async function getAnthropicClient({
hasFallbackApiKey,
})
if (!isClaudeSubscriber && !usingOpenAICodex) {
if (!isClaudeSubscriber && !usingOpenAICodex && !usingGrok) {
await configureApiKeyHeaders(defaultHeaders, getIsNonInteractiveSession())
}
const resolvedFetch = usingOpenAICodex
? buildOpenAICodexFetch(fetchOverride, source)
: buildFetch(fetchOverride, source)
const resolvedFetch = usingGrok
? buildGrokFetch(fetchOverride, source)
: usingOpenAICodex
? buildOpenAICodexFetch(fetchOverride, source)
: buildFetch(fetchOverride, source)
const stagingOAuthBaseUrl = process.env.USER_TYPE === 'ant' &&
isEnvTruthy(process.env.USE_STAGING_OAUTH)
? getOauthConfig().BASE_API_URL
@ -377,10 +386,12 @@ export async function getAnthropicClient({
const clientConfig: ConstructorParameters<typeof Anthropic>[0] = {
apiKey: usingOpenAICodex
? OPENAI_OAUTH_DUMMY_KEY
: usingGrok
? GROK_OAUTH_DUMMY_KEY
: isClaudeSubscriber
? null
: resolveAnthropicClientApiKey({ explicitApiKey: apiKey }),
authToken: isClaudeSubscriber && !usingOpenAICodex
authToken: isClaudeSubscriber && !usingOpenAICodex && !usingGrok
? getClaudeAIOAuthTokens()?.accessToken
: undefined,
// Set baseURL from OAuth config when using staging OAuth

View File

@ -0,0 +1,64 @@
import { describe, expect, test } from 'bun:test'
import {
buildGrokAuthorizeUrl,
exchangeGrokCodeForTokens,
generateGrokCodeVerifier,
generateGrokNonce,
generateGrokState,
GROK_OAUTH_CLIENT_ID,
GROK_OAUTH_TOKEN_ENDPOINT,
refreshGrokTokens,
} from './client.js'
describe('Grok OAuth client', () => {
test('builds the xAI PKCE authorization request', () => {
const verifier = generateGrokCodeVerifier()
expect(verifier).toMatch(/^[A-Za-z0-9_-]{43}$/)
expect(generateGrokState()).toMatch(/^[a-f0-9]{64}$/)
expect(generateGrokNonce()).toMatch(/^[a-f0-9]{32}$/)
const url = new URL(buildGrokAuthorizeUrl({
redirectUri: 'http://127.0.0.1:56121/callback',
codeVerifier: verifier,
state: 'state',
nonce: 'nonce',
}))
expect(url.origin + url.pathname).toBe('https://auth.x.ai/oauth2/authorize')
expect(url.searchParams.get('client_id')).toBe(GROK_OAUTH_CLIENT_ID)
expect(url.searchParams.get('scope')).toBe(
'openid profile email offline_access grok-cli:access api:access conversations:read conversations:write',
)
expect(url.searchParams.get('code_challenge_method')).toBe('S256')
expect(url.searchParams.has('plan')).toBe(false)
expect(url.searchParams.has('referrer')).toBe(false)
})
test('exchanges and refreshes tokens without a client secret', async () => {
const requests: Array<{ url: string; body: URLSearchParams }> = []
const fetchOverride: typeof fetch = async (input, init) => {
requests.push({
url: String(input),
body: new URLSearchParams(String(init?.body)),
})
return Response.json({ access_token: 'access', refresh_token: 'refresh' })
}
await exchangeGrokCodeForTokens({
code: 'code',
codeVerifier: 'verifier',
redirectUri: 'http://127.0.0.1:56121/callback',
fetchOverride,
})
await refreshGrokTokens('refresh', { fetchOverride })
expect(requests.map((request) => request.url)).toEqual([
GROK_OAUTH_TOKEN_ENDPOINT,
GROK_OAUTH_TOKEN_ENDPOINT,
])
expect(requests[0]!.body.get('grant_type')).toBe('authorization_code')
expect(requests[0]!.body.get('code_verifier')).toBe('verifier')
expect(requests[0]!.body.has('client_secret')).toBe(false)
expect(requests[1]!.body.get('grant_type')).toBe('refresh_token')
expect(requests[1]!.body.get('refresh_token')).toBe('refresh')
})
})

View File

@ -0,0 +1,205 @@
import { randomBytes } from 'crypto'
import { generateCodeChallenge } from '../oauth/crypto.js'
import type {
GrokJwtClaims,
GrokOAuthTokenResponse,
GrokOAuthTokens,
} from './types.js'
export const GROK_OAUTH_ISSUER = 'https://auth.x.ai'
export const GROK_OAUTH_AUTHORIZE_ENDPOINT =
`${GROK_OAUTH_ISSUER}/oauth2/authorize`
export const GROK_OAUTH_TOKEN_ENDPOINT = `${GROK_OAUTH_ISSUER}/oauth2/token`
export const GROK_OAUTH_CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828'
export const GROK_OAUTH_SCOPE =
'openid profile email offline_access grok-cli:access api:access conversations:read conversations:write'
export const GROK_OAUTH_REDIRECT_PATH = '/callback'
const DEFAULT_TOKEN_LIFETIME_SECONDS = 6 * 60 * 60
const TOKEN_EXPIRY_SKEW_MS = 5 * 60_000
const TOKEN_ERROR_BODY_LIMIT = 500
export type GrokTokenFetchOptions = {
fetchOverride?: typeof fetch
proxyUrl?: string | null
timeoutMs?: number
clientId?: string
}
export function generateGrokState(): string {
return randomBytes(32).toString('hex')
}
export function generateGrokNonce(): string {
return randomBytes(16).toString('hex')
}
export function generateGrokCodeVerifier(): string {
return randomBytes(32).toString('base64url')
}
export function buildGrokAuthorizeUrl(input: {
redirectUri: string
codeVerifier: string
state: string
nonce: string
clientId?: string
}): string {
const params = new URLSearchParams({
response_type: 'code',
client_id: input.clientId?.trim() || GROK_OAUTH_CLIENT_ID,
redirect_uri: input.redirectUri,
scope: GROK_OAUTH_SCOPE,
state: input.state,
nonce: input.nonce,
code_challenge: generateCodeChallenge(input.codeVerifier),
code_challenge_method: 'S256',
})
return `${GROK_OAUTH_AUTHORIZE_ENDPOINT}?${params.toString()}`
}
export async function exchangeGrokCodeForTokens(input: {
code: string
redirectUri: string
codeVerifier: string
} & GrokTokenFetchOptions): Promise<GrokOAuthTokenResponse> {
return requestGrokTokens(
new URLSearchParams({
grant_type: 'authorization_code',
client_id: input.clientId?.trim() || GROK_OAUTH_CLIENT_ID,
code: input.code,
redirect_uri: input.redirectUri,
code_verifier: input.codeVerifier,
}),
'exchange',
input,
)
}
export async function refreshGrokTokens(
refreshToken: string,
options: GrokTokenFetchOptions = {},
): Promise<GrokOAuthTokenResponse> {
return requestGrokTokens(
new URLSearchParams({
grant_type: 'refresh_token',
client_id: options.clientId?.trim() || GROK_OAUTH_CLIENT_ID,
refresh_token: refreshToken,
}),
'refresh',
options,
)
}
async function requestGrokTokens(
body: URLSearchParams,
operation: 'exchange' | 'refresh',
options: GrokTokenFetchOptions,
): Promise<GrokOAuthTokenResponse> {
const fetchOverride = options.fetchOverride ?? globalThis.fetch
const proxyOptions = options.proxyUrl
? getGrokProxyFetchOptions(options.proxyUrl)
: {}
const response = await fetchOverride(GROK_OAUTH_TOKEN_ENDPOINT, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'cc-haha-grok-oauth/1.0',
},
body: body.toString(),
...(options.timeoutMs
? { signal: AbortSignal.timeout(options.timeoutMs) }
: {}),
...(await proxyOptions),
})
if (!response.ok) {
const raw = await response.text().catch(() => '')
const sanitized = sanitizeTokenError(raw)
throw new Error(
`Grok token ${operation} failed: ${response.status}${sanitized ? `: ${sanitized}` : ''}`,
)
}
return (await response.json()) as GrokOAuthTokenResponse
}
async function getGrokProxyFetchOptions(
proxyUrl: string,
): Promise<RequestInit> {
const { getProxyFetchOptions } = await import('../../utils/proxy.js')
return getProxyFetchOptions({ proxyUrl })
}
function sanitizeTokenError(body: string): string {
return body
.replace(
/"((?:access_token|refresh_token|id_token|code|code_verifier))"\s*:\s*"[^"]*"/gi,
'"$1":"[redacted]"',
)
.replace(
/\b(access_token|refresh_token|id_token|code|code_verifier)=([^&\s]+)/gi,
'$1=[redacted]',
)
.slice(0, TOKEN_ERROR_BODY_LIMIT)
}
function parseJwtClaims(token?: string): GrokJwtClaims | undefined {
if (!token) return undefined
const parts = token.split('.')
if (parts.length !== 3) return undefined
try {
return JSON.parse(Buffer.from(parts[1]!, 'base64url').toString())
} catch {
return undefined
}
}
export function normalizeGrokTokens(
response: GrokOAuthTokenResponse,
): GrokOAuthTokens {
if (!response.access_token) {
throw new Error('Grok OAuth response did not include an access token')
}
if (!response.refresh_token) {
throw new Error('Grok OAuth response did not include a refresh token')
}
const claims =
parseJwtClaims(response.id_token) ?? parseJwtClaims(response.access_token)
return {
accessToken: response.access_token,
refreshToken: response.refresh_token,
expiresAt:
Date.now() +
(response.expires_in ?? DEFAULT_TOKEN_LIFETIME_SECONDS) * 1000,
...(response.id_token && { idToken: response.id_token }),
...(claims?.email && { email: claims.email }),
clientId: GROK_OAUTH_CLIENT_ID,
...(response.scope && { scope: response.scope }),
tokenType: response.token_type || 'Bearer',
}
}
export function withRefreshedGrokAccessToken(
existing: GrokOAuthTokens,
response: GrokOAuthTokenResponse,
): GrokOAuthTokens {
const claims =
parseJwtClaims(response.id_token) ?? parseJwtClaims(response.access_token)
return {
...existing,
accessToken: response.access_token,
refreshToken: response.refresh_token ?? existing.refreshToken,
expiresAt:
Date.now() +
(response.expires_in ?? DEFAULT_TOKEN_LIFETIME_SECONDS) * 1000,
idToken: response.id_token ?? existing.idToken,
email: claims?.email ?? existing.email,
clientId: existing.clientId ?? GROK_OAUTH_CLIENT_ID,
scope: response.scope ?? existing.scope,
tokenType: response.token_type ?? existing.tokenType ?? 'Bearer',
}
}
export function isGrokTokenExpired(expiresAt: number): boolean {
return expiresAt - Date.now() <= TOKEN_EXPIRY_SKEW_MS
}

View File

@ -0,0 +1,177 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'fs/promises'
import * as os from 'os'
import * as path from 'path'
import {
buildGrokFetch,
GROK_CLI_API_ENDPOINT,
GROK_CLI_VERSION,
} from './fetch.js'
import { GROK_OAUTH_FILE_ENV_KEY } from './storage.js'
import { GROK_OAUTH_TOKEN_ENDPOINT } from './client.js'
describe('Grok Responses fetch adapter', () => {
let tmpDir: string
let original: string | undefined
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grok-fetch-'))
original = process.env[GROK_OAUTH_FILE_ENV_KEY]
process.env[GROK_OAUTH_FILE_ENV_KEY] = path.join(tmpDir, 'tokens.json')
await fs.writeFile(process.env[GROK_OAUTH_FILE_ENV_KEY], JSON.stringify({
accessToken: 'access',
refreshToken: 'refresh',
expiresAt: Date.now() + 3_600_000,
}))
})
afterEach(async () => {
if (original === undefined) delete process.env[GROK_OAUTH_FILE_ENV_KEY]
else process.env[GROK_OAUTH_FILE_ENV_KEY] = original
await fs.rm(tmpDir, { recursive: true, force: true })
})
test('maps Anthropic messages to the exact subscription endpoint and identity', async () => {
let call: { url: string; headers: Headers; body: Record<string, unknown> } | undefined
const fetchOverride: typeof fetch = async (input, init) => {
call = {
url: String(input),
headers: new Headers(init?.headers),
body: JSON.parse(String(init?.body)),
}
return new Response([
'event: response.completed',
'data: {"response":{"id":"resp_1","object":"response","created_at":1,"model":"grok-4.5","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}',
'',
].join('\n'), { headers: { 'Content-Type': 'text/event-stream' } })
}
const grokFetch = buildGrokFetch(fetchOverride, 'test')
const response = await grokFetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
body: JSON.stringify({
model: 'grok-4.5', max_tokens: 64,
output_config: { effort: 'max' },
messages: [{ role: 'user', content: 'Say ok' }],
}),
})
expect(call?.url).toBe(GROK_CLI_API_ENDPOINT)
expect(call?.headers.get('Authorization')).toBe('Bearer access')
expect(call?.headers.get('X-XAI-Token-Auth')).toBe('xai-grok-cli')
expect(call?.headers.get('x-grok-client-version')).toBe(GROK_CLI_VERSION)
expect(call?.headers.get('User-Agent')).toBe(`xai-grok-workspace/${GROK_CLI_VERSION}`)
expect(call?.headers.get('x-grok-model-override')).toBe('grok-4.5')
expect(call?.body.model).toBe('grok-4.5')
expect(call?.body.reasoning).toEqual({ effort: 'high' })
expect(call?.body.stream).toBe(true)
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
type: 'message', content: [{ type: 'text', text: 'ok' }],
})
})
test('drops Claude reasoning effort for Grok models that reject it', async () => {
let upstreamBody: Record<string, unknown> | undefined
const fetchOverride: typeof fetch = async (_input, init) => {
upstreamBody = JSON.parse(String(init?.body))
return new Response([
'event: response.completed',
'data: {"response":{"id":"resp_no_effort","object":"response","created_at":1,"model":"grok-composer-2.5-fast","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}',
'',
].join('\n'), { headers: { 'Content-Type': 'text/event-stream' } })
}
const response = await buildGrokFetch(fetchOverride, 'test')(
'https://api.anthropic.com/v1/messages',
{ method: 'POST', body: JSON.stringify({
model: 'grok-composer-2.5-fast',
max_tokens: 64,
output_config: { effort: 'max' },
messages: [{ role: 'user', content: 'hello' }],
}) },
)
expect(response.status).toBe(200)
expect(upstreamBody?.reasoning).toBeUndefined()
})
test('translates subscription SSE back to Anthropic streaming events', async () => {
const fetchOverride: typeof fetch = async () => new Response([
'event: response.created',
'data: {"id":"resp_2","object":"response","created_at":1,"model":"grok-4.5","status":"in_progress"}',
'',
'event: response.content_part.added',
'data: {"output_index":0,"content_index":0,"part":{"type":"output_text","text":""}}',
'',
'event: response.output_text.delta',
'data: {"output_index":0,"content_index":0,"delta":"hello"}',
'',
'event: response.output_text.done',
'data: {"output_index":0,"content_index":0,"text":"hello"}',
'',
'event: response.completed',
'data: {"response":{"id":"resp_2","object":"response","created_at":1,"model":"grok-4.5","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}',
'',
].join('\n'), { headers: { 'Content-Type': 'text/event-stream' } })
const response = await buildGrokFetch(fetchOverride, 'test')(
'https://api.anthropic.com/v1/messages',
{ method: 'POST', body: JSON.stringify({
model: 'claude-opus-4-1', max_tokens: 64, stream: true,
messages: [{ role: 'user', content: 'hello' }],
}) },
)
expect(response.headers.get('Content-Type')).toContain('text/event-stream')
expect(await response.text()).toContain('text_delta')
})
test('refreshes once after a 401, persists rotation, and retries with the new access token', async () => {
const inferenceAuth: string[] = []
const fetchOverride: typeof fetch = async (input, init) => {
if (String(input) === GROK_OAUTH_TOKEN_ENDPOINT) {
return Response.json({
access_token: 'new-access',
refresh_token: 'new-refresh',
expires_in: 3600,
})
}
inferenceAuth.push(new Headers(init?.headers).get('Authorization') ?? '')
if (inferenceAuth.length === 1) return new Response('expired', { status: 401 })
return new Response([
'event: response.completed',
'data: {"response":{"id":"resp_retry","object":"response","created_at":1,"model":"grok-4.5","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}',
'',
].join('\n'), { headers: { 'Content-Type': 'text/event-stream' } })
}
const response = await buildGrokFetch(fetchOverride, 'test')(
'https://api.anthropic.com/v1/messages',
{ method: 'POST', body: JSON.stringify({
model: 'grok-4.5', max_tokens: 64,
messages: [{ role: 'user', content: 'hello' }],
}) },
)
expect(response.status).toBe(200)
expect(inferenceAuth).toEqual(['Bearer access', 'Bearer new-access'])
expect(JSON.parse(await fs.readFile(process.env[GROK_OAUTH_FILE_ENV_KEY]!, 'utf8'))).toMatchObject({
accessToken: 'new-access',
refreshToken: 'new-refresh',
})
})
test('does not refresh-loop on entitlement failures', async () => {
let calls = 0
const response = await buildGrokFetch(async () => {
calls += 1
return new Response('subscription required', { status: 403 })
}, 'test')(
'https://api.anthropic.com/v1/messages',
{ method: 'POST', body: JSON.stringify({
model: 'grok-4.5', max_tokens: 64,
messages: [{ role: 'user', content: 'hello' }],
}) },
)
expect(response.status).toBe(403)
expect(calls).toBe(1)
})
})

View File

@ -0,0 +1,134 @@
import { anthropicToOpenaiResponses } from '../../server/proxy/transform/anthropicToOpenaiResponses.js'
import { openaiResponsesStreamToAnthropic } from '../../server/proxy/streaming/openaiResponsesStreamToAnthropic.js'
import { openaiResponsesStreamToAnthropicResponse } from '../../server/proxy/streaming/openaiResponsesStreamToAnthropicResponse.js'
import type { AnthropicRequest } from '../../server/proxy/transform/types.js'
import { ensureFreshGrokTokens, forceRefreshGrokTokens } from './refresh.js'
import { grokModelRejectsReasoningEffort, resolveGrokModel } from './models.js'
import { getGrokOAuthTokens } from './storage.js'
export const GROK_CLI_BASE_URL = 'https://cli-chat-proxy.grok.com/v1'
export const GROK_CLI_API_ENDPOINT = `${GROK_CLI_BASE_URL}/responses`
export const GROK_CLI_VERSION = '0.2.99'
export const GROK_OAUTH_DUMMY_KEY = 'grok-oauth-dummy-key'
export function shouldUseGrokAuth(): boolean {
return !!getGrokOAuthTokens()?.refreshToken
}
export function buildGrokIdentityHeaders(accessToken: string): Headers {
return new Headers({
Accept: 'application/json, text/event-stream',
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'X-XAI-Token-Auth': 'xai-grok-cli',
'x-grok-client-version': GROK_CLI_VERSION,
'User-Agent': `xai-grok-workspace/${GROK_CLI_VERSION}`,
})
}
export function buildGrokFetch(
fetchOverride: typeof fetch | undefined,
source: string | undefined,
): typeof fetch {
const inner = fetchOverride ?? globalThis.fetch
return async (input, init) => {
const url = input instanceof Request ? new URL(input.url) : new URL(String(input))
if (!url.pathname.endsWith('/v1/messages')) return inner(input, init)
const originalBody = await readAnthropicBody(input, init)
const requestedModel = resolveGrokModel(originalBody.model)
const transformedBody = anthropicToOpenaiResponses({
...originalBody,
model: requestedModel,
})
transformedBody.model = requestedModel
transformedBody.stream = true
if (grokModelRejectsReasoningEffort(requestedModel)) {
delete transformedBody.reasoning
}
const tokens = await ensureFreshGrokTokens({ fetchOverride: inner })
if (!tokens) {
throw new Error(
'Grok OAuth token is missing or expired. Authorize Grok again in the desktop app.',
)
}
const headers = buildGrokIdentityHeaders(tokens.accessToken)
headers.set('x-grok-model-override', requestedModel)
void source
const requestUpstream = (requestHeaders: Headers) => inner(GROK_CLI_API_ENDPOINT, {
method: 'POST',
headers: requestHeaders,
body: JSON.stringify(transformedBody),
signal: init?.signal,
})
let upstream = await requestUpstream(headers)
if (upstream.status === 401) {
const refreshed = await forceRefreshGrokTokens({ fetchOverride: inner })
if (refreshed) {
const refreshedHeaders = buildGrokIdentityHeaders(refreshed.accessToken)
refreshedHeaders.set('x-grok-model-override', requestedModel)
upstream = await requestUpstream(refreshedHeaders)
}
}
if (!upstream.ok) {
const errorText = await upstream.text().catch(() => '')
return Response.json(
{
type: 'error',
error: {
type: 'api_error',
message: `Grok upstream returned HTTP ${upstream.status}: ${errorText.slice(0, 500)}`,
},
},
{ status: upstream.status },
)
}
if (!upstream.body) {
return Response.json(
{ type: 'error', error: { type: 'api_error', message: 'Grok upstream returned no stream body' } },
{ status: 502 },
)
}
if (originalBody.stream) {
return new Response(
openaiResponsesStreamToAnthropic(upstream.body, requestedModel),
{
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
},
)
}
return Response.json(
await openaiResponsesStreamToAnthropicResponse(
upstream.body,
requestedModel,
),
)
}
}
async function readAnthropicBody(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<AnthropicRequest> {
if (typeof init?.body === 'string') {
return JSON.parse(init.body) as AnthropicRequest
}
if (init?.body instanceof Uint8Array || init?.body instanceof ArrayBuffer) {
return JSON.parse(Buffer.from(init.body).toString('utf8')) as AnthropicRequest
}
if (input instanceof Request) {
return (await input.clone().json()) as AnthropicRequest
}
throw new Error('Unable to read Anthropic request body for Grok transformation')
}

View File

@ -0,0 +1,7 @@
export * from './client.js'
export * from './fetch.js'
export * from './modelCatalog.js'
export * from './models.js'
export * from './refresh.js'
export * from './storage.js'
export type * from './types.js'

View File

@ -0,0 +1,83 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'fs/promises'
import * as os from 'os'
import * as path from 'path'
import {
fetchGrokModelCatalog,
getGrokModelCatalog,
GROK_MODELS_ENDPOINT,
} from './modelCatalog.js'
import { GROK_CLI_VERSION } from './fetch.js'
import { GROK_OAUTH_FILE_ENV_KEY } from './storage.js'
describe('Grok model catalog', () => {
let tmpDir: string
let original: string | undefined
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grok-models-'))
original = process.env[GROK_OAUTH_FILE_ENV_KEY]
process.env[GROK_OAUTH_FILE_ENV_KEY] = path.join(tmpDir, 'tokens.json')
await fs.writeFile(process.env[GROK_OAUTH_FILE_ENV_KEY], JSON.stringify({
accessToken: 'access', refreshToken: 'refresh', expiresAt: Date.now() + 3_600_000,
}))
})
afterEach(async () => {
if (original === undefined) delete process.env[GROK_OAUTH_FILE_ENV_KEY]
else process.env[GROK_OAUTH_FILE_ENV_KEY] = original
await fs.rm(tmpDir, { recursive: true, force: true })
})
test('normalizes the official models response and filters non-API entries', async () => {
let seen: { url: string; headers: Headers } | undefined
const models = await fetchGrokModelCatalog(async (input, init) => {
seen = { url: String(input), headers: new Headers(init?.headers) }
return Response.json({ models: {
build: {
info: {
id: 'grok-build',
name: 'Grok Build',
context_window: 500000,
supported_in_api: false,
},
},
frontier: {
info: {
id: 'grok-4.5',
name: 'Grok 4.5',
context_window: 500000,
supported_in_api: true,
supports_reasoning_effort: true,
reasoning_effort: 'high',
reasoning_efforts: [
{ value: 'high', default: true },
{ value: 'medium' },
{ value: 'low' },
],
},
},
} })
})
expect(seen?.url).toBe(GROK_MODELS_ENDPOINT)
expect(seen?.headers.get('Authorization')).toBe('Bearer access')
expect(seen?.headers.get('x-grok-client-version')).toBe(GROK_CLI_VERSION)
expect(GROK_MODELS_ENDPOINT).toEndWith('/v1/models')
expect(models.map((model) => model.value)).toEqual(['grok-4.5'])
expect(models[0]).toMatchObject({
contextWindow: 500000,
supportsReasoningEffort: true,
reasoningEffort: 'high',
reasoningEfforts: ['high', 'medium', 'low'],
})
})
test('falls back to the static public catalog on failure', async () => {
const models = await getGrokModelCatalog({
forceRefresh: true,
fetchOverride: async () => new Response('nope', { status: 503 }),
})
expect(models[0]?.value).toBe('grok-4.5')
expect(models.some((model) => model.value === 'grok-4.5')).toBe(true)
})
})

View File

@ -0,0 +1,166 @@
import {
buildGrokIdentityHeaders,
GROK_CLI_BASE_URL,
} from './fetch.js'
import { ensureFreshGrokTokens } from './refresh.js'
import {
GROK_DEFAULT_CONTEXT_WINDOW,
GROK_MODEL_CATALOG,
type GrokModelCatalogEntry,
} from './models.js'
export const GROK_MODELS_ENDPOINT = `${GROK_CLI_BASE_URL}/models`
const MODEL_CATALOG_TTL_MS = 5 * 60_000
let cachedCatalog: {
accountKey: string
expiresAt: number
models: GrokModelCatalogEntry[]
} | null = null
export async function fetchGrokModelCatalog(
fetchOverride: typeof fetch = globalThis.fetch,
accessToken?: string,
): Promise<GrokModelCatalogEntry[]> {
const token = accessToken || (await ensureFreshGrokTokens({ fetchOverride }))?.accessToken
if (!token) throw new Error('Grok OAuth token is unavailable')
const response = await fetchOverride(GROK_MODELS_ENDPOINT, {
method: 'GET',
headers: buildGrokIdentityHeaders(token),
signal: AbortSignal.timeout(5_000),
})
if (!response.ok) {
throw new Error(`Grok models endpoint returned HTTP ${response.status}`)
}
const body = await response.json() as unknown
const rows = extractModelRows(body)
const models = rows
.map(normalizeRemoteModel)
.filter((model): model is GrokModelCatalogEntry => model !== null)
if (!models.length) throw new Error('Grok models endpoint returned no models')
return models
}
export async function getGrokModelCatalog(options?: {
fetchOverride?: typeof fetch
forceRefresh?: boolean
accessToken?: string
accountKey?: string
}): Promise<GrokModelCatalogEntry[]> {
const accountKey = options?.accountKey ?? (options?.accessToken ? 'authenticated' : 'default')
if (
!options?.forceRefresh &&
cachedCatalog?.accountKey === accountKey &&
cachedCatalog.expiresAt > Date.now()
) {
return cachedCatalog.models
}
try {
const models = await fetchGrokModelCatalog(
options?.fetchOverride,
options?.accessToken,
)
cachedCatalog = {
accountKey,
expiresAt: Date.now() + MODEL_CATALOG_TTL_MS,
models,
}
return models
} catch {
return GROK_MODEL_CATALOG
}
}
export function clearGrokModelCatalogCache(): void {
cachedCatalog = null
}
function extractModelRows(body: unknown): unknown[] {
if (Array.isArray(body)) return body
if (!body || typeof body !== 'object') return []
const record = body as Record<string, unknown>
if (Array.isArray(record.models)) return record.models
if (isKeyedObject(record.models)) return keyedModelRows(record.models)
if (Array.isArray(record.data)) return record.data
if (isKeyedObject(record.data)) return keyedModelRows(record.data)
return keyedModelRows(record)
}
function keyedModelRows(record: Record<string, unknown>): unknown[] {
return Object.entries(record)
.filter(([, value]) => value && typeof value === 'object' && !Array.isArray(value))
.map(([key, value]) => ({ ...(value as Record<string, unknown>), __key: key }))
}
function normalizeRemoteModel(value: unknown): GrokModelCatalogEntry | null {
if (!value || typeof value !== 'object') return null
const outer = value as Record<string, unknown>
const meta = isKeyedObject(outer.meta) ? outer.meta : {}
const info = isKeyedObject(outer.info) ? outer.info : {}
const record = { ...outer, ...meta, ...info }
if (record.hidden === true || record.supported_in_api === false || record.supportedInApi === false) {
return null
}
const id = firstString(record.id, record.slug, record.model, record.value, record.__key)
if (!id) return null
const fallback = GROK_MODEL_CATALOG.find((model) => model.value === id)
const label = firstString(record.display_name, record.displayName, record.name, record.label) ?? fallback?.label ?? id
const description = firstString(record.description) ?? fallback?.description ?? ''
const contextWindow = firstNumber(
record.totalContextTokens,
record.total_context_tokens,
record.context_window,
record.contextWindow,
) ?? fallback?.contextWindow ?? GROK_DEFAULT_CONTEXT_WINDOW
const supportsReasoningEffort = firstBoolean(
record.supportsReasoningEffort,
record.supports_reasoning_effort,
)
const reasoningEffort = firstString(
record.reasoningEffort,
record.reasoning_effort,
)
const reasoningEfforts = firstStringArray(
record.reasoningEfforts,
record.reasoning_efforts,
)
return {
value: id,
label,
description,
contextWindow,
source: fallback?.source ?? 'official',
...(supportsReasoningEffort !== undefined && { supportsReasoningEffort }),
...(reasoningEffort && { reasoningEffort }),
...(reasoningEfforts && { reasoningEfforts }),
}
}
function firstString(...values: unknown[]): string | undefined {
return values.find((value): value is string => typeof value === 'string' && !!value.trim())?.trim()
}
function firstNumber(...values: unknown[]): number | undefined {
return values.find((value): value is number => typeof value === 'number' && Number.isFinite(value) && value > 0)
}
function firstBoolean(...values: unknown[]): boolean | undefined {
return values.find((value): value is boolean => typeof value === 'boolean')
}
function firstStringArray(...values: unknown[]): string[] | undefined {
for (const value of values) {
if (!Array.isArray(value)) continue
const strings = value.flatMap((item) => {
if (typeof item === 'string' && item.trim()) return [item.trim()]
if (!isKeyedObject(item)) return []
const candidate = firstString(item.value, item.id)
return candidate ? [candidate] : []
})
if (strings.length) return strings
}
return undefined
}
function isKeyedObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}

View File

@ -0,0 +1,26 @@
import { describe, expect, test } from 'bun:test'
import {
GROK_DEFAULT_MAIN_MODEL,
GROK_MODEL_CATALOG,
getGrokContextWindowForModel,
resolveGrokModel,
} from './models.js'
describe('Grok model catalog', () => {
test('keeps CLI-only aliases out of the picker fallback and defaults to Grok 4.5', () => {
expect(GROK_MODEL_CATALOG.map((model) => model.value)).toEqual([
'grok-4.5',
'grok-composer-2.5-fast',
])
expect(GROK_DEFAULT_MAIN_MODEL).toBe('grok-4.5')
expect(resolveGrokModel('claude-opus-4-1')).toBe(GROK_DEFAULT_MAIN_MODEL)
})
test('preserves explicit model IDs and resolves supported aliases', () => {
expect(resolveGrokModel('grok-composer-2.5-fast')).toBe('grok-composer-2.5-fast')
expect(resolveGrokModel('grok')).toBe(GROK_DEFAULT_MAIN_MODEL)
expect(resolveGrokModel('unknown-model')).toBe(GROK_DEFAULT_MAIN_MODEL)
expect(getGrokContextWindowForModel('grok-4.5')).toBe(500_000)
expect(getGrokContextWindowForModel('unknown-model')).toBe(500_000)
})
})

View File

@ -0,0 +1,56 @@
export const GROK_DEFAULT_MAIN_MODEL = 'grok-4.5'
export const GROK_DEFAULT_SONNET_MODEL = GROK_DEFAULT_MAIN_MODEL
export const GROK_DEFAULT_HAIKU_MODEL = GROK_DEFAULT_MAIN_MODEL
export const GROK_DEFAULT_MODEL = GROK_DEFAULT_MAIN_MODEL
export const GROK_DEFAULT_CONTEXT_WINDOW = 500_000
export type GrokModelCatalogEntry = {
value: string
label: string
description: string
contextWindow?: number
source?: 'official' | 'cli'
supportsReasoningEffort?: boolean
reasoningEffort?: string
reasoningEfforts?: string[]
}
export const GROK_MODEL_CATALOG: GrokModelCatalogEntry[] = [
{
...model('grok-4.5', 'Grok 4.5', 'Grok frontier text model', 500_000),
supportsReasoningEffort: true,
reasoningEffort: 'high',
reasoningEfforts: ['high', 'medium', 'low'],
},
{
...model('grok-composer-2.5-fast', 'Composer 2.5', 'Grok coding model', 200_000),
supportsReasoningEffort: false,
},
]
function model(
value: string,
label: string,
description: string,
contextWindow: number,
source: 'official' | 'cli' = 'official',
): GrokModelCatalogEntry {
return { value, label, description, contextWindow, source }
}
const EXPLICIT_MODELS = new Set(GROK_MODEL_CATALOG.map((entry) => entry.value))
export function resolveGrokModel(modelId: string): string {
const normalized = modelId.trim().toLowerCase()
return EXPLICIT_MODELS.has(normalized) ? normalized : GROK_DEFAULT_MAIN_MODEL
}
export function getGrokContextWindowForModel(modelId: string): number | null {
const resolved = resolveGrokModel(modelId)
return GROK_MODEL_CATALOG.find((model) => model.value === resolved)?.contextWindow ?? null
}
export function grokModelRejectsReasoningEffort(modelId: string): boolean {
const resolved = resolveGrokModel(modelId)
return resolved === 'grok-composer-2.5-fast'
}

View File

@ -0,0 +1,50 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'fs/promises'
import * as os from 'os'
import * as path from 'path'
import { clearFreshGrokTokenCache, ensureFreshGrokTokens } from './refresh.js'
import { GROK_OAUTH_FILE_ENV_KEY } from './storage.js'
describe('Grok token refresh helper', () => {
let tmpDir: string
let tokenFile: string
let original: string | undefined
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grok-refresh-'))
tokenFile = path.join(tmpDir, 'tokens.json')
original = process.env[GROK_OAUTH_FILE_ENV_KEY]
process.env[GROK_OAUTH_FILE_ENV_KEY] = tokenFile
clearFreshGrokTokenCache()
await fs.writeFile(tokenFile, JSON.stringify({
accessToken: 'expired', refreshToken: 'old-refresh', expiresAt: 1,
}))
})
afterEach(async () => {
clearFreshGrokTokenCache()
if (original === undefined) delete process.env[GROK_OAUTH_FILE_ENV_KEY]
else process.env[GROK_OAUTH_FILE_ENV_KEY] = original
await fs.rm(tmpDir, { recursive: true, force: true })
})
test('refreshes once and persists a rotated refresh token', async () => {
let calls = 0
const fetchOverride: typeof fetch = async () => {
calls++
return Response.json({
access_token: 'fresh-access', refresh_token: 'rotated-refresh', expires_in: 3600,
})
}
await expect(ensureFreshGrokTokens({ fetchOverride })).resolves.toMatchObject({
accessToken: 'fresh-access', refreshToken: 'rotated-refresh',
})
await expect(ensureFreshGrokTokens({ fetchOverride })).resolves.toMatchObject({
accessToken: 'fresh-access', refreshToken: 'rotated-refresh',
})
expect(calls).toBe(1)
expect(JSON.parse(await fs.readFile(tokenFile, 'utf8'))).toMatchObject({
accessToken: 'fresh-access', refreshToken: 'rotated-refresh',
})
})
})

View File

@ -0,0 +1,69 @@
import {
isGrokTokenExpired,
refreshGrokTokens,
withRefreshedGrokAccessToken,
type GrokTokenFetchOptions,
} from './client.js'
import {
getGrokOAuthTokenFilePath,
getGrokOAuthTokensAsync,
saveGrokOAuthTokens,
} from './storage.js'
import type { GrokOAuthTokens } from './types.js'
let refreshedCache: {
authority: string
refreshToken: string
tokens: GrokOAuthTokens
} | null = null
export async function ensureFreshGrokTokens(
options: GrokTokenFetchOptions = {},
): Promise<GrokOAuthTokens | null> {
const authority = getGrokOAuthTokenFilePath()
if (!authority) return null
const stored = await getGrokOAuthTokensAsync()
if (!stored) return null
if (
refreshedCache?.authority === authority &&
refreshedCache.refreshToken === stored.refreshToken &&
!isGrokTokenExpired(refreshedCache.tokens.expiresAt)
) {
return refreshedCache.tokens
}
if (!isGrokTokenExpired(stored.expiresAt)) return stored
return refreshStoredGrokTokens(authority, stored, options)
}
export async function forceRefreshGrokTokens(
options: GrokTokenFetchOptions = {},
): Promise<GrokOAuthTokens | null> {
const authority = getGrokOAuthTokenFilePath()
if (!authority) return null
const stored = await getGrokOAuthTokensAsync()
if (!stored) return null
return refreshStoredGrokTokens(authority, stored, options)
}
async function refreshStoredGrokTokens(
authority: string,
stored: GrokOAuthTokens,
options: GrokTokenFetchOptions,
): Promise<GrokOAuthTokens> {
const response = await refreshGrokTokens(stored.refreshToken, {
...options,
clientId: stored.clientId ?? options.clientId,
})
const tokens = withRefreshedGrokAccessToken(stored, response)
if (!saveGrokOAuthTokens(tokens)) {
throw new Error('Failed to persist refreshed Grok OAuth tokens')
}
refreshedCache = { authority, refreshToken: stored.refreshToken, tokens }
return tokens
}
export function clearFreshGrokTokenCache(): void {
refreshedCache = null
}

View File

@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'fs/promises'
import * as os from 'os'
import * as path from 'path'
import {
getGrokOAuthTokens,
getGrokOAuthTokensAsync,
GROK_OAUTH_FILE_ENV_KEY,
saveGrokOAuthTokens,
} from './storage.js'
describe('Grok OAuth desktop token file', () => {
let tmpDir: string
let tokenFile: string
let original: string | undefined
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grok-oauth-'))
tokenFile = path.join(tmpDir, 'tokens.json')
original = process.env[GROK_OAUTH_FILE_ENV_KEY]
process.env[GROK_OAUTH_FILE_ENV_KEY] = tokenFile
})
afterEach(async () => {
if (original === undefined) delete process.env[GROK_OAUTH_FILE_ENV_KEY]
else process.env[GROK_OAUTH_FILE_ENV_KEY] = original
await fs.rm(tmpDir, { recursive: true, force: true })
})
test('reads camelCase desktop tokens without modifying the file', async () => {
const raw = JSON.stringify({
accessToken: 'access',
refreshToken: 'refresh',
expiresAt: 4_100_000_000_000,
email: 'user@example.com',
})
await fs.writeFile(tokenFile, raw)
expect(getGrokOAuthTokens()).toMatchObject({ accessToken: 'access' })
await expect(getGrokOAuthTokensAsync()).resolves.toMatchObject({
refreshToken: 'refresh',
})
expect(await fs.readFile(tokenFile, 'utf8')).toBe(raw)
})
test('accepts xAI snake_case token response fields and rejects missing authority', async () => {
await fs.writeFile(tokenFile, JSON.stringify({
access_token: 'access',
refresh_token: 'refresh',
expires_at: '2099-01-01T00:00:00.000Z',
token_type: 'Bearer',
}))
expect(getGrokOAuthTokens()).toMatchObject({
accessToken: 'access',
refreshToken: 'refresh',
tokenType: 'Bearer',
})
delete process.env[GROK_OAUTH_FILE_ENV_KEY]
expect(getGrokOAuthTokens()).toBeNull()
await expect(getGrokOAuthTokensAsync()).resolves.toBeNull()
})
test('atomically persists refreshed tokens for desktop and rotated refresh tokens', async () => {
expect(saveGrokOAuthTokens({
accessToken: 'fresh-access',
refreshToken: 'rotated-refresh',
expiresAt: 4_100_000_000_000,
})).toBe(true)
await expect(getGrokOAuthTokensAsync()).resolves.toMatchObject({
accessToken: 'fresh-access',
refreshToken: 'rotated-refresh',
})
const entries = await fs.readdir(tmpDir)
expect(entries.filter((entry) => entry.includes('.tmp.'))).toEqual([])
})
})

View File

@ -0,0 +1,108 @@
import * as fs from 'fs'
import * as path from 'path'
import type { GrokOAuthTokens } from './types.js'
export const GROK_OAUTH_FILE_ENV_KEY = 'GROK_OAUTH_FILE'
export function getGrokOAuthTokenFilePath(): string | null {
return process.env[GROK_OAUTH_FILE_ENV_KEY]?.trim() || null
}
export function getGrokOAuthTokens(): GrokOAuthTokens | null {
const filePath = getGrokOAuthTokenFilePath()
if (!filePath) return null
try {
return normalizeTokenFile(JSON.parse(fs.readFileSync(filePath, 'utf8')))
} catch {
return null
}
}
export async function getGrokOAuthTokensAsync(): Promise<GrokOAuthTokens | null> {
const filePath = getGrokOAuthTokenFilePath()
if (!filePath) return null
try {
return normalizeTokenFile(
JSON.parse(await fs.promises.readFile(filePath, 'utf8')),
)
} catch {
return null
}
}
export function saveGrokOAuthTokens(tokens: GrokOAuthTokens): boolean {
const filePath = getGrokOAuthTokenFilePath()
if (!filePath) return false
const temporaryPath = `${filePath}.tmp.${process.pid}.${Date.now()}`
let renamed = false
try {
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(temporaryPath, `${JSON.stringify(tokens, null, 2)}\n`, {
mode: 0o600,
})
fs.renameSync(temporaryPath, filePath)
renamed = true
return true
} catch {
return false
} finally {
if (!renamed) {
try {
fs.rmSync(temporaryPath, { force: true })
} catch {
// Best-effort cleanup; never expose token contents in an error.
}
}
}
}
function normalizeTokenFile(value: unknown): GrokOAuthTokens | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const record = value as Record<string, unknown>
const accessToken = stringField(record, 'accessToken', 'access_token')
const refreshToken = stringField(record, 'refreshToken', 'refresh_token')
const expiresAt = expiryField(record.expiresAt ?? record.expires_at)
if (!accessToken || !refreshToken || expiresAt === null) return null
return {
accessToken,
refreshToken,
expiresAt,
...optionalString(record, 'idToken', 'id_token'),
...optionalString(record, 'email'),
...optionalString(record, 'clientId', 'client_id'),
...optionalString(record, 'scope'),
...optionalString(record, 'tokenType', 'token_type'),
}
}
function stringField(
record: Record<string, unknown>,
...keys: string[]
): string | undefined {
for (const key of keys) {
if (typeof record[key] === 'string' && record[key].trim()) {
return record[key].trim()
}
}
return undefined
}
function optionalString(
record: Record<string, unknown>,
target: keyof GrokOAuthTokens,
source = target,
): Partial<GrokOAuthTokens> {
const value = stringField(record, target, source)
return value ? { [target]: value } : {}
}
function expiryField(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim()) {
const numeric = Number(value)
if (Number.isFinite(numeric)) return numeric
const parsed = Date.parse(value)
if (Number.isFinite(parsed)) return parsed
}
return null
}

View File

@ -0,0 +1,23 @@
export type GrokOAuthTokenResponse = {
access_token: string
refresh_token?: string
id_token?: string
expires_in?: number
scope?: string
token_type?: string
}
export type GrokOAuthTokens = {
accessToken: string
refreshToken: string
expiresAt: number
idToken?: string
email?: string
clientId?: string
scope?: string
tokenType?: string
}
export type GrokJwtClaims = {
email?: string
}

View File

@ -34,7 +34,7 @@ export class AuthCodeListener {
* This avoids race conditions by keeping the server open until it's used.
* @param port Optional specific port to use. If not provided, uses OS-assigned port.
*/
async start(port?: number): Promise<number> {
async start(port?: number, host = 'localhost'): Promise<number> {
return new Promise((resolve, reject) => {
this.localServer.once('error', err => {
reject(
@ -43,7 +43,7 @@ export class AuthCodeListener {
})
// Listen on specified port or 0 to let the OS assign an available port
this.localServer.listen(port ?? 0, 'localhost', () => {
this.localServer.listen(port ?? 0, host, () => {
const address = this.localServer.address() as AddressInfo
this.port = address.port
resolve(this.port)

View File

@ -43,6 +43,8 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([
'CLAUDE_CODE_SKIP_AZURE_OPENAI_AUTH',
'CC_HAHA_OPENAI_OAUTH_PROVIDER',
'OPENAI_CODEX_OAUTH_FILE',
'CC_HAHA_GROK_OAUTH_PROVIDER',
'GROK_OAUTH_FILE',
// Model defaults — often set to provider-specific ID formats
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',