diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx
index 1a6ee8b1..b6508fd5 100644
--- a/desktop/src/__tests__/generalSettings.test.tsx
+++ b/desktop/src/__tests__/generalSettings.test.tsx
@@ -88,6 +88,10 @@ vi.mock('../components/settings/ChatGPTOfficialLogin', () => ({
ChatGPTOfficialLogin: () =>
,
}))
+vi.mock('../components/settings/GrokOfficialLogin', () => ({
+ GrokOfficialLogin: () => ,
+}))
+
vi.mock('../pages/AdapterSettings', () => ({
AdapterSettings: () => Adapter Settings Mock
,
}))
@@ -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()
+
+ 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',
])
})
diff --git a/desktop/src/api/hahaGrokOAuth.ts b/desktop/src/api/hahaGrokOAuth.ts
new file mode 100644
index 00000000..cf65f8fb
--- /dev/null
+++ b/desktop/src/api/hahaGrokOAuth.ts
@@ -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('/api/haha-grok-oauth')
+ },
+
+ successUrl() {
+ return `${getBaseUrl()}/api/haha-grok-oauth/success`
+ },
+
+ logout() {
+ return api.delete<{ ok: true }>('/api/haha-grok-oauth')
+ },
+}
diff --git a/desktop/src/api/providers.ts b/desktop/src/api/providers.ts
index 35e50413..00adabeb 100644
--- a/desktop/src/api/providers.ts
+++ b/desktop/src/api/providers.ts
@@ -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
}
diff --git a/desktop/src/components/controls/ModelSelector.test.tsx b/desktop/src/components/controls/ModelSelector.test.tsx
index 6dc3ca70..d9a6f969 100644
--- a/desktop/src/components/controls/ModelSelector.test.tsx
+++ b/desktop/src/components/controls/ModelSelector.test.tsx
@@ -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()
+ 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()
+
+ 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,
diff --git a/desktop/src/components/controls/ModelSelector.tsx b/desktop/src/components/controls/ModelSelector.tsx
index a14f17c4..666bfdd9 100644
--- a/desktop/src/components/controls/ModelSelector.tsx
+++ b/desktop/src/components/controls/ModelSelector.tsx
@@ -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(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(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(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(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(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,
diff --git a/desktop/src/components/settings/GrokOfficialLogin.test.tsx b/desktop/src/components/settings/GrokOfficialLogin.test.tsx
new file mode 100644
index 00000000..9f67f595
--- /dev/null
+++ b/desktop/src/components/settings/GrokOfficialLogin.test.tsx
@@ -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()
+ 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()
+ 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')
+ })
+ })
+})
diff --git a/desktop/src/components/settings/GrokOfficialLogin.tsx b/desktop/src/components/settings/GrokOfficialLogin.tsx
new file mode 100644
index 00000000..9b6bf0a3
--- /dev/null
+++ b/desktop/src/components/settings/GrokOfficialLogin.tsx
@@ -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(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 ? (
+
+ ) : null
+
+ if (status === null) {
+ return (
+
+ {error ? (
+
{t('settings.grokOfficialLogin.errorPrefix')}{error}
+ ) : (
+
{t('common.loading')}
+ )}
+ {manualAuthorizeButton}
+
+ )
+ }
+
+ if (status.loggedIn) {
+ return (
+
+
+ {t('settings.grokOfficialLogin.loggedInPrefix')} {status.email || t('settings.grokOfficialLogin.accountUnknown')}
+
+
+
+ )
+ }
+
+ return (
+
+
{t('settings.grokOfficialLogin.intro')}
+
+ {error &&
{t('settings.grokOfficialLogin.errorPrefix')}{error}
}
+ {manualAuthorizeButton}
+
+ )
+}
diff --git a/desktop/src/constants/grokOfficialProvider.ts b/desktop/src/constants/grokOfficialProvider.ts
new file mode 100644
index 00000000..b8ad6d0b
--- /dev/null
+++ b/desktop/src/constants/grokOfficialProvider.ts
@@ -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: [],
+ },
+]
diff --git a/desktop/src/constants/openaiOfficialProvider.ts b/desktop/src/constants/openaiOfficialProvider.ts
index ac5cf1cf..c4190563 100644
--- a/desktop/src/constants/openaiOfficialProvider.ts
+++ b/desktop/src/constants/openaiOfficialProvider.ts
@@ -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'
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts
index c71e711f..fcb1cf18 100644
--- a/desktop/src/i18n/locales/en.ts
+++ b/desktop/src/i18n/locales/en.ts
@@ -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)',
diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts
index c99e8a73..84b1cb47 100644
--- a/desktop/src/i18n/locales/jp.ts
+++ b/desktop/src/i18n/locales/jp.ts
@@ -432,6 +432,17 @@ export const jp: Record = {
'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 = {
'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)',
diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts
index 4b6c1f68..6d4770df 100644
--- a/desktop/src/i18n/locales/kr.ts
+++ b/desktop/src/i18n/locales/kr.ts
@@ -432,6 +432,17 @@ export const kr: Record = {
'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 = {
'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)',
diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts
index bbda05c0..4ded849e 100644
--- a/desktop/src/i18n/locales/zh-TW.ts
+++ b/desktop/src/i18n/locales/zh-TW.ts
@@ -432,6 +432,17 @@ export const zh: Record = {
'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 = {
'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)',
diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts
index 2323ad31..eb9b01c1 100644
--- a/desktop/src/i18n/locales/zh.ts
+++ b/desktop/src/i18n/locales/zh.ts
@@ -432,6 +432,17 @@ export const zh: Record = {
'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 = {
'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)',
diff --git a/desktop/src/lib/runtimeSelection.ts b/desktop/src/lib/runtimeSelection.ts
index fbf38552..dbee52fb 100644
--- a/desktop/src/lib/runtimeSelection.ts
+++ b/desktop/src/lib/runtimeSelection.ts
@@ -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
),
}
}
diff --git a/desktop/src/pages/EmptySession.test.tsx b/desktop/src/pages/EmptySession.test.tsx
index 95933b25..2d0a255b 100644
--- a/desktop/src/pages/EmptySession.test.tsx
+++ b/desktop/src/pages/EmptySession.test.tsx
@@ -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,
})
diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx
index 884ad9b4..2a07a723 100644
--- a/desktop/src/pages/Settings.tsx
+++ b/desktop/src/pages/Settings.tsx
@@ -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([
[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 (
@@ -513,6 +520,28 @@ function ProviderSettings() {
)
}
+ if (item.kind === 'grok-official') {
+ return (
+
handleActivate(GROK_OFFICIAL_PROVIDER_ID) : undefined}
+ title={t('settings.providers.grokOfficialName')}
+ subtitle={t('settings.providers.grokOfficialDesc')}
+ badges={isGrokOfficialActive ? (
+ {t('settings.providers.default')}
+ ) : null}
+ details={isGrokOfficialActive ? (
+
+
+
+ ) : null}
+ />
+ )
+ }
+
const provider = item.provider
const isActive = activeId === provider.id
const test = testResults[provider.id]
diff --git a/desktop/src/stores/hahaGrokOAuthStore.test.ts b/desktop/src/stores/hahaGrokOAuthStore.test.ts
new file mode 100644
index 00000000..db222ba0
--- /dev/null
+++ b/desktop/src/stores/hahaGrokOAuthStore.test.ts
@@ -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)
+ })
+})
diff --git a/desktop/src/stores/hahaGrokOAuthStore.ts b/desktop/src/stores/hahaGrokOAuthStore.ts
new file mode 100644
index 00000000..2956ae72
--- /dev/null
+++ b/desktop/src/stores/hahaGrokOAuthStore.ts
@@ -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
+ login: () => Promise<{ authorizeUrl: string }>
+ logout: () => Promise
+ startPolling: () => void
+ stopPolling: () => void
+}
+
+export const useHahaGrokOAuthStore = create((set, get) => {
+ let pollTimer: ReturnType | 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 })
+ },
+ }
+})
diff --git a/desktop/src/stores/providerStore.test.ts b/desktop/src/stores/providerStore.test.ts
index fa1f98ae..7fa15cb8 100644
--- a/desktop/src/stores/providerStore.test.ts
+++ b/desktop/src/stores/providerStore.test.ts
@@ -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'])
})
diff --git a/desktop/src/stores/providerStore.ts b/desktop/src/stores/providerStore.ts
index ffb68d6d..728595a9 100644
--- a/desktop/src/stores/providerStore.ts
+++ b/desktop/src/stores/providerStore.ts
@@ -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((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
diff --git a/desktop/src/stores/sessionRuntimeStore.test.ts b/desktop/src/stores/sessionRuntimeStore.test.ts
new file mode 100644
index 00000000..257a7445
--- /dev/null
+++ b/desktop/src/stores/sessionRuntimeStore.test.ts
@@ -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,
+ })
+ })
+})
diff --git a/desktop/src/stores/sessionRuntimeStore.ts b/desktop/src/stores/sessionRuntimeStore.ts
index d6d864f0..853308c5 100644
--- a/desktop/src/stores/sessionRuntimeStore.ts
+++ b/desktop/src/stores/sessionRuntimeStore.ts
@@ -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,
+): { selections: Record; 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 {
if (typeof localStorage === 'undefined') return {}
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as Record
- 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((set) => ({
set((state) => {
const selections = {
...state.selections,
- [key]: selection,
+ [key]: normalizeSelection(selection),
}
persistSelections(selections)
return { selections }
@@ -74,11 +123,11 @@ export const useSessionRuntimeStore = create((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 &&
diff --git a/desktop/src/types/provider.ts b/desktop/src/types/provider.ts
index fabfcfc4..8036ec9a 100644
--- a/desktop/src/types/provider.ts
+++ b/desktop/src/types/provider.ts
@@ -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
diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts
index 00c4acc7..5a246ae6 100644
--- a/src/server/__tests__/conversation-service.test.ts
+++ b/src/server/__tests__/conversation-service.test.ts
@@ -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
+
+ 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'
diff --git a/src/server/__tests__/haha-grok-oauth-api.test.ts b/src/server/__tests__/haha-grok-oauth-api.test.ts
new file mode 100644
index 00000000..87598dc6
--- /dev/null
+++ b/src/server/__tests__/haha-grok-oauth-api.test.ts
@@ -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()
+ })
+})
diff --git a/src/server/__tests__/haha-grok-oauth-service.test.ts b/src/server/__tests__/haha-grok-oauth-service.test.ts
new file mode 100644
index 00000000..58a1447c
--- /dev/null
+++ b/src/server/__tests__/haha-grok-oauth-service.test.ts
@@ -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',
+ })
+ })
+})
diff --git a/src/server/__tests__/persistence-upgrade.test.ts b/src/server/__tests__/persistence-upgrade.test.ts
index f678865b..42e17872 100644
--- a/src/server/__tests__/persistence-upgrade.test.ts
+++ b/src/server/__tests__/persistence-upgrade.test.ts
@@ -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([])
})
diff --git a/src/server/__tests__/provider-runtime-env.test.ts b/src/server/__tests__/provider-runtime-env.test.ts
index 755a0411..19b78321 100644
--- a/src/server/__tests__/provider-runtime-env.test.ts
+++ b/src/server/__tests__/provider-runtime-env.test.ts
@@ -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 {
await fs.mkdir(path.dirname(filePath), { recursive: true })
@@ -18,12 +20,52 @@ async function writeJson(filePath: string, value: unknown): Promise {
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',
diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts
index c82a564b..5e3d26ea 100644
--- a/src/server/__tests__/providers.test.ts
+++ b/src/server/__tests__/providers.test.ts
@@ -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
+ 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 () => {
diff --git a/src/server/__tests__/settings.test.ts b/src/server/__tests__/settings.test.ts
index 726ae1ea..e7c1ec72 100644
--- a/src/server/__tests__/settings.test.ts
+++ b/src/server/__tests__/settings.test.ts
@@ -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)
diff --git a/src/server/api/haha-grok-oauth.ts b/src/server/api/haha-grok-oauth.ts
new file mode 100644
index 00000000..8febd90c
--- /dev/null
+++ b/src/server/api/haha-grok-oauth.ts
@@ -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 {
+ 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)
+ }
+}
diff --git a/src/server/api/models.ts b/src/server/api/models.ts
index b7c5ae0b..050ebefb 100644
--- a/src/server/api/models.ts
+++ b/src/server/api/models.ts
@@ -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 {
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 {
+ 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 {
},
})
}
+ 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 {
// 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 {
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 {
// 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)
diff --git a/src/server/router.ts b/src/server/router.ts
index 5a735824..af80655d 100644
--- a/src/server/router.ts
+++ b/src/server/router.ts
@@ -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 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',
diff --git a/src/server/services/doctorService.ts b/src/server/services/doctorService.ts
index 5c373fa9..4b03c506 100644
--- a/src/server/services/doctorService.ts
+++ b/src/server/services/doctorService.ts
@@ -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) {
diff --git a/src/server/services/grokOfficialProvider.ts b/src/server/services/grokOfficialProvider.ts
new file mode 100644
index 00000000..f836b730
--- /dev/null
+++ b/src/server/services/grokOfficialProvider.ts
@@ -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 {
+ 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,
+ }
+}
diff --git a/src/server/services/hahaGrokOAuthService.ts b/src/server/services/hahaGrokOAuthService.ts
new file mode 100644
index 00000000..f403489c
--- /dev/null
+++ b/src/server/services/hahaGrokOAuthService.ts
@@ -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
+}
+
+type GrokRefreshFn = (
+ refreshToken: string,
+ options?: GrokTokenFetchOptions,
+) => Promise
+
+const SESSION_TTL_MS = 10 * 60 * 1000
+const CALLBACK_PATH = '/callback'
+
+export const GROK_OAUTH_SUCCESS_HTML = `Grok Login Success
+
+✓ Grok Login Successful
Authorization is complete. You can close this window and return to Claude Code Haha.
`
+
+function renderErrorHtml(message: string): string {
+ return `Grok Login Failed
+
+Grok Login Failed
${escapeHtml(message)} `
+}
+
+function escapeHtml(value: string): string {
+ return value
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+}
+
+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()
+ private refreshFn: GrokRefreshFn = refreshGrokTokens
+
+ setRefreshFn(fn: GrokRefreshFn): void {
+ this.refreshFn = fn
+ }
+
+ getOAuthFilePath(): string {
+ return getHahaGrokOAuthFilePath()
+ }
+
+ async loadTokens(): Promise {
+ 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 {
+ 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 {
+ await fs.rm(this.getOAuthFilePath(), { force: true })
+ }
+
+ async startSession(): Promise {
+ 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 {
+ 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 {
+ 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 {
+ const settings = await loadNetworkSettings()
+ return {
+ proxyUrl: getManualNetworkProxyUrl(settings),
+ timeoutMs: settings.aiRequestTimeoutMs,
+ }
+ }
+}
+
+export const hahaGrokOAuthService = new HahaGrokOAuthService()
diff --git a/src/server/services/persistentStorageMigrations.ts b/src/server/services/persistentStorageMigrations.ts
index d7119631..477877e5 100644
--- a/src/server/services/persistentStorageMigrations.ts
+++ b/src/server/services/persistentStorageMigrations.ts
@@ -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
diff --git a/src/server/services/providerRuntimeEnv.ts b/src/server/services/providerRuntimeEnv.ts
index 9cfe9b99..c1e44388 100644
--- a/src/server/services/providerRuntimeEnv.ts
+++ b/src/server/services/providerRuntimeEnv.ts
@@ -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
}
diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts
index 44f25361..0b6db1cb 100644
--- a/src/server/services/providerService.ts
+++ b/src/server/services/providerService.ts
@@ -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)
diff --git a/src/server/types/provider.ts b/src/server/types/provider.ts
index 884723cb..8b3a89a1 100644
--- a/src/server/types/provider.ts
+++ b/src/server/types/provider.ts
@@ -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
export const ProviderRuntimeKindSchema = z.enum([
'anthropic_compatible',
'openai_oauth',
+ 'grok_oauth',
])
export type ProviderRuntimeKind = z.infer
diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts
index 7c622f16..e6d48eff 100644
--- a/src/server/ws/handler.ts
+++ b/src/server/ws/handler.ts
@@ -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
) {
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
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 {
+ 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
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 {
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
diff --git a/src/services/api/client.test.ts b/src/services/api/client.test.ts
index 5ec4764e..1f79c8b9 100644
--- a/src/services/api/client.test.ts
+++ b/src/services/api/client.test.ts
@@ -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
diff --git a/src/services/api/client.ts b/src/services/api/client.ts
index 17c32f1a..f437bef4 100644
--- a/src/services/api/client.ts
+++ b/src/services/api/client.ts
@@ -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[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
diff --git a/src/services/grokAuth/client.test.ts b/src/services/grokAuth/client.test.ts
new file mode 100644
index 00000000..558590b9
--- /dev/null
+++ b/src/services/grokAuth/client.test.ts
@@ -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')
+ })
+})
diff --git a/src/services/grokAuth/client.ts b/src/services/grokAuth/client.ts
new file mode 100644
index 00000000..e4552650
--- /dev/null
+++ b/src/services/grokAuth/client.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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
+}
diff --git a/src/services/grokAuth/fetch.test.ts b/src/services/grokAuth/fetch.test.ts
new file mode 100644
index 00000000..c119ccfa
--- /dev/null
+++ b/src/services/grokAuth/fetch.test.ts
@@ -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 } | 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 | 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)
+ })
+})
diff --git a/src/services/grokAuth/fetch.ts b/src/services/grokAuth/fetch.ts
new file mode 100644
index 00000000..371cfda1
--- /dev/null
+++ b/src/services/grokAuth/fetch.ts
@@ -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 {
+ 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')
+}
diff --git a/src/services/grokAuth/index.ts b/src/services/grokAuth/index.ts
new file mode 100644
index 00000000..33a51647
--- /dev/null
+++ b/src/services/grokAuth/index.ts
@@ -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'
diff --git a/src/services/grokAuth/modelCatalog.test.ts b/src/services/grokAuth/modelCatalog.test.ts
new file mode 100644
index 00000000..f42c84cc
--- /dev/null
+++ b/src/services/grokAuth/modelCatalog.test.ts
@@ -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)
+ })
+})
diff --git a/src/services/grokAuth/modelCatalog.ts b/src/services/grokAuth/modelCatalog.ts
new file mode 100644
index 00000000..7e6d1509
--- /dev/null
+++ b/src/services/grokAuth/modelCatalog.ts
@@ -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 {
+ 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 {
+ 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
+ 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): unknown[] {
+ return Object.entries(record)
+ .filter(([, value]) => value && typeof value === 'object' && !Array.isArray(value))
+ .map(([key, value]) => ({ ...(value as Record), __key: key }))
+}
+
+function normalizeRemoteModel(value: unknown): GrokModelCatalogEntry | null {
+ if (!value || typeof value !== 'object') return null
+ const outer = value as Record
+ 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 {
+ return !!value && typeof value === 'object' && !Array.isArray(value)
+}
diff --git a/src/services/grokAuth/models.test.ts b/src/services/grokAuth/models.test.ts
new file mode 100644
index 00000000..7f2d61ed
--- /dev/null
+++ b/src/services/grokAuth/models.test.ts
@@ -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)
+ })
+})
diff --git a/src/services/grokAuth/models.ts b/src/services/grokAuth/models.ts
new file mode 100644
index 00000000..e3259b7e
--- /dev/null
+++ b/src/services/grokAuth/models.ts
@@ -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'
+}
diff --git a/src/services/grokAuth/refresh.test.ts b/src/services/grokAuth/refresh.test.ts
new file mode 100644
index 00000000..0fcb701e
--- /dev/null
+++ b/src/services/grokAuth/refresh.test.ts
@@ -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',
+ })
+ })
+})
diff --git a/src/services/grokAuth/refresh.ts b/src/services/grokAuth/refresh.ts
new file mode 100644
index 00000000..77791610
--- /dev/null
+++ b/src/services/grokAuth/refresh.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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
+}
diff --git a/src/services/grokAuth/storage.test.ts b/src/services/grokAuth/storage.test.ts
new file mode 100644
index 00000000..af9bc323
--- /dev/null
+++ b/src/services/grokAuth/storage.test.ts
@@ -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([])
+ })
+})
diff --git a/src/services/grokAuth/storage.ts b/src/services/grokAuth/storage.ts
new file mode 100644
index 00000000..4cb1a407
--- /dev/null
+++ b/src/services/grokAuth/storage.ts
@@ -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 {
+ 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
+ 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,
+ ...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,
+ target: keyof GrokOAuthTokens,
+ source = target,
+): Partial {
+ 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
+}
diff --git a/src/services/grokAuth/types.ts b/src/services/grokAuth/types.ts
new file mode 100644
index 00000000..e8a9110e
--- /dev/null
+++ b/src/services/grokAuth/types.ts
@@ -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
+}
diff --git a/src/services/oauth/auth-code-listener.ts b/src/services/oauth/auth-code-listener.ts
index a46c33ec..2945d8f5 100644
--- a/src/services/oauth/auth-code-listener.ts
+++ b/src/services/oauth/auth-code-listener.ts
@@ -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 {
+ async start(port?: number, host = 'localhost'): Promise {
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)
diff --git a/src/utils/managedEnvConstants.ts b/src/utils/managedEnvConstants.ts
index 701be0f2..614b0968 100644
--- a/src/utils/managedEnvConstants.ts
+++ b/src/utils/managedEnvConstants.ts
@@ -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',