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',