mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
feat(provider): add Grok official OAuth support
This commit is contained in:
parent
5d3c126d50
commit
5178d7ceec
@ -88,6 +88,10 @@ vi.mock('../components/settings/ChatGPTOfficialLogin', () => ({
|
||||
ChatGPTOfficialLogin: () => <div data-testid="chatgpt-official-login" />,
|
||||
}))
|
||||
|
||||
vi.mock('../components/settings/GrokOfficialLogin', () => ({
|
||||
GrokOfficialLogin: () => <div data-testid="grok-official-login" />,
|
||||
}))
|
||||
|
||||
vi.mock('../pages/AdapterSettings', () => ({
|
||||
AdapterSettings: () => <div>Adapter Settings Mock</div>,
|
||||
}))
|
||||
@ -1571,7 +1575,7 @@ describe('Settings > Providers tab', () => {
|
||||
notes: '',
|
||||
},
|
||||
]
|
||||
providerStoreState.providerOrder = ['provider-1', 'claude-official', 'openai-official']
|
||||
providerStoreState.providerOrder = ['provider-1', 'claude-official', 'openai-official', 'grok-official']
|
||||
providerStoreState.activeId = null
|
||||
providerStoreState.hasLoadedProviders = true
|
||||
})
|
||||
@ -1620,6 +1624,18 @@ describe('Settings > Providers tab', () => {
|
||||
expect(screen.queryByTestId('claude-official-login')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Grok Official as the active built-in provider', () => {
|
||||
providerStoreState.providers = []
|
||||
providerStoreState.activeId = 'grok-official'
|
||||
|
||||
render(<Settings />)
|
||||
|
||||
const provider = screen.getByTestId('grok-official-provider')
|
||||
expect(within(provider).getByText('Grok Official')).toBeInTheDocument()
|
||||
expect(within(provider).getByText('Default')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('grok-official-login')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders saved and official providers in the stored sortable order', () => {
|
||||
providerStoreState.providerOrder = ['provider-1', 'openai-official', 'claude-official']
|
||||
|
||||
@ -1631,6 +1647,7 @@ describe('Settings > Providers tab', () => {
|
||||
'provider-provider-1',
|
||||
'openai-official-provider',
|
||||
'claude-official-provider',
|
||||
'grok-official-provider',
|
||||
])
|
||||
})
|
||||
|
||||
@ -1645,6 +1662,7 @@ describe('Settings > Providers tab', () => {
|
||||
'provider-provider-1',
|
||||
'claude-official-provider',
|
||||
'openai-official-provider',
|
||||
'grok-official-provider',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
39
desktop/src/api/hahaGrokOAuth.ts
Normal file
39
desktop/src/api/hahaGrokOAuth.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { api, getBaseUrl } from './client'
|
||||
|
||||
export type HahaGrokOAuthStatus =
|
||||
| { loggedIn: false }
|
||||
| {
|
||||
loggedIn: true
|
||||
expiresAt: number | null
|
||||
email: string | null
|
||||
}
|
||||
|
||||
function currentServerPort(): number {
|
||||
const port = new URL(getBaseUrl()).port
|
||||
const parsed = Number.parseInt(port, 10)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error(`Cannot determine server port from baseUrl: ${getBaseUrl()}`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
export const hahaGrokOAuthApi = {
|
||||
start() {
|
||||
return api.post<{ authorizeUrl: string; state: string }>(
|
||||
'/api/haha-grok-oauth/start',
|
||||
{ serverPort: currentServerPort() },
|
||||
)
|
||||
},
|
||||
|
||||
status() {
|
||||
return api.get<HahaGrokOAuthStatus>('/api/haha-grok-oauth')
|
||||
},
|
||||
|
||||
successUrl() {
|
||||
return `${getBaseUrl()}/api/haha-grok-oauth/success`
|
||||
},
|
||||
|
||||
logout() {
|
||||
return api.delete<{ ok: true }>('/api/haha-grok-oauth')
|
||||
},
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
import { ModelSelector } from './ModelSelector'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useHahaOAuthStore } from '../../stores/hahaOAuthStore'
|
||||
import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore'
|
||||
import { useHahaGrokOAuthStore } from '../../stores/hahaGrokOAuthStore'
|
||||
import { useProviderStore } from '../../stores/providerStore'
|
||||
import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
@ -32,11 +33,13 @@ afterEach(() => {
|
||||
useChatStore.setState(useChatStore.getInitialState(), true)
|
||||
useHahaOAuthStore.setState(useHahaOAuthStore.getInitialState(), true)
|
||||
useHahaOpenAIOAuthStore.setState(useHahaOpenAIOAuthStore.getInitialState(), true)
|
||||
useHahaGrokOAuthStore.setState(useHahaGrokOAuthStore.getInitialState(), true)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
useHahaOAuthStore.setState({ fetchStatus: async () => {} })
|
||||
useHahaOpenAIOAuthStore.setState({ fetchStatus: async () => {} })
|
||||
useHahaGrokOAuthStore.setState({ fetchStatus: async () => {} })
|
||||
})
|
||||
|
||||
describe('ModelSelector', () => {
|
||||
@ -453,9 +456,93 @@ describe('ModelSelector', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('selects Grok Official models for a logged-in runtime', async () => {
|
||||
const grokModels: ModelInfo[] = [{
|
||||
id: 'grok-4.5',
|
||||
name: 'Grok 4.5',
|
||||
description: 'Grok frontier text model',
|
||||
context: '',
|
||||
supportedReasoningEfforts: [],
|
||||
}]
|
||||
useHahaGrokOAuthStore.setState({
|
||||
status: { loggedIn: true, expiresAt: null, email: 'grok@example.com' },
|
||||
fetchStatus: async () => {},
|
||||
})
|
||||
useSettingsStore.setState({
|
||||
locale: 'en',
|
||||
availableModels: grokModels,
|
||||
currentModel: grokModels[0],
|
||||
activeProviderName: 'Grok Official',
|
||||
})
|
||||
useProviderStore.setState({
|
||||
providers: [],
|
||||
activeId: 'grok-official',
|
||||
hasLoadedProviders: true,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<ModelSelector runtimeKey="session-grok" />)
|
||||
await clickByRole(/Grok 4\.5/i)
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /Grok 4\.5/i })[1]!)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(useSessionRuntimeStore.getState().selections['session-grok']).toMatchObject({
|
||||
providerId: 'grok-official',
|
||||
modelId: 'grok-4.5',
|
||||
})
|
||||
expect(screen.queryByRole('button', { name: /Effort:/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('replaces a stale Grok runtime model with the current official default', async () => {
|
||||
const grokModels: ModelInfo[] = [{
|
||||
id: 'grok-4.5',
|
||||
name: 'Grok 4.5',
|
||||
description: 'Grok frontier text model',
|
||||
context: '500000',
|
||||
defaultReasoningEffort: 'high',
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high'],
|
||||
}]
|
||||
useHahaGrokOAuthStore.setState({
|
||||
status: { loggedIn: true, expiresAt: null, email: 'grok@example.com' },
|
||||
fetchStatus: async () => {},
|
||||
})
|
||||
useSettingsStore.setState({
|
||||
locale: 'en',
|
||||
availableModels: grokModels,
|
||||
currentModel: grokModels[0],
|
||||
activeProviderName: 'Grok Official',
|
||||
effortLevel: 'max',
|
||||
})
|
||||
useProviderStore.setState({
|
||||
providers: [],
|
||||
activeId: 'grok-official',
|
||||
hasLoadedProviders: true,
|
||||
isLoading: false,
|
||||
})
|
||||
useSessionRuntimeStore.getState().setSelection('session-stale-grok', {
|
||||
providerId: 'grok-official',
|
||||
modelId: 'grok-build',
|
||||
effortLevel: 'max',
|
||||
})
|
||||
render(<ModelSelector runtimeKey="session-stale-grok" />)
|
||||
|
||||
expect(screen.queryByText('grok-build')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Grok 4.5, Grok Official' })).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(useSessionRuntimeStore.getState().selections['session-stale-grok']).toEqual({
|
||||
providerId: 'grok-official',
|
||||
modelId: 'grok-4.5',
|
||||
effortLevel: 'high',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('hides official provider sections when OAuth is not logged in', async () => {
|
||||
useHahaOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: async () => {} })
|
||||
useHahaOpenAIOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: async () => {} })
|
||||
useHahaGrokOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: async () => {} })
|
||||
useSettingsStore.setState({
|
||||
locale: 'en',
|
||||
availableModels: MODELS,
|
||||
|
||||
@ -18,6 +18,11 @@ import { isDesktopRuntime } from '../../lib/desktopRuntime'
|
||||
import { resolveDefaultRuntimeSelection } from '../../lib/runtimeSelection'
|
||||
import { useHahaOAuthStore } from '../../stores/hahaOAuthStore'
|
||||
import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore'
|
||||
import { useHahaGrokOAuthStore } from '../../stores/hahaGrokOAuthStore'
|
||||
import {
|
||||
GROK_OFFICIAL_MODELS,
|
||||
GROK_OFFICIAL_PROVIDER_ID,
|
||||
} from '../../constants/grokOfficialProvider'
|
||||
import { MobileBottomSheet } from '../shared/MobileBottomSheet'
|
||||
import { ReasoningEffortPopover } from './ReasoningEffortPopover'
|
||||
|
||||
@ -108,9 +113,11 @@ function buildProviderChoices(
|
||||
availableModels: ModelInfo[],
|
||||
officialName: string,
|
||||
openAIOfficialName: string,
|
||||
grokOfficialName: string,
|
||||
labels: Record<'main' | 'haiku' | 'sonnet' | 'opus', string>,
|
||||
claudeOfficialLoggedIn: boolean,
|
||||
openAIOfficialLoggedIn: boolean,
|
||||
grokOfficialLoggedIn: boolean,
|
||||
): ProviderChoice[] {
|
||||
const claudeOfficialModels = activeId === null && availableModels.length > 0
|
||||
? availableModels
|
||||
@ -118,6 +125,9 @@ function buildProviderChoices(
|
||||
const openAIOfficialModels = activeId === OPENAI_OFFICIAL_PROVIDER_ID && availableModels.length > 0
|
||||
? availableModels
|
||||
: OPENAI_OFFICIAL_MODELS
|
||||
const grokOfficialModels = activeId === GROK_OFFICIAL_PROVIDER_ID && availableModels.length > 0
|
||||
? availableModels
|
||||
: GROK_OFFICIAL_MODELS
|
||||
|
||||
const choices: ProviderChoice[] = []
|
||||
|
||||
@ -132,6 +142,14 @@ function buildProviderChoices(
|
||||
openAIOfficialName,
|
||||
))
|
||||
}
|
||||
if (grokOfficialLoggedIn) {
|
||||
choices.push(officialChoices(
|
||||
GROK_OFFICIAL_PROVIDER_ID,
|
||||
grokOfficialModels,
|
||||
activeId === GROK_OFFICIAL_PROVIDER_ID,
|
||||
grokOfficialName,
|
||||
))
|
||||
}
|
||||
|
||||
for (const provider of providers) {
|
||||
choices.push({
|
||||
@ -173,6 +191,8 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
|
||||
const fetchClaudeOAuthStatus = useHahaOAuthStore((s) => s.fetchStatus)
|
||||
const openAIOAuthStatus = useHahaOpenAIOAuthStore((s) => s.status)
|
||||
const fetchOpenAIOAuthStatus = useHahaOpenAIOAuthStore((s) => s.fetchStatus)
|
||||
const grokOAuthStatus = useHahaGrokOAuthStore((s) => s.status)
|
||||
const fetchGrokOAuthStatus = useHahaGrokOAuthStore((s) => s.fetchStatus)
|
||||
const runtimeSelection = useSessionRuntimeStore((state) =>
|
||||
runtimeKey ? state.selections[runtimeKey] : undefined,
|
||||
)
|
||||
@ -217,7 +237,8 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
|
||||
requestedOAuthStatusRef.current = true
|
||||
void fetchClaudeOAuthStatus()
|
||||
void fetchOpenAIOAuthStatus()
|
||||
}, [fetchClaudeOAuthStatus, fetchOpenAIOAuthStatus, isRuntimeScoped, open])
|
||||
void fetchGrokOAuthStatus()
|
||||
}, [fetchClaudeOAuthStatus, fetchGrokOAuthStatus, fetchOpenAIOAuthStatus, isRuntimeScoped, open])
|
||||
|
||||
const openSelector = useCallback(() => {
|
||||
if (!disabled) {
|
||||
@ -318,11 +339,13 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
|
||||
availableModels,
|
||||
t('settings.providers.officialName'),
|
||||
t('settings.providers.openaiOfficialName'),
|
||||
t('settings.providers.grokOfficialName'),
|
||||
roleLabels,
|
||||
claudeOAuthStatus?.loggedIn === true,
|
||||
openAIOAuthStatus?.loggedIn === true,
|
||||
grokOAuthStatus?.loggedIn === true,
|
||||
),
|
||||
[activeId, availableModels, providers, roleLabels, t, claudeOAuthStatus, openAIOAuthStatus],
|
||||
[activeId, availableModels, providers, roleLabels, t, claudeOAuthStatus, grokOAuthStatus, openAIOAuthStatus],
|
||||
)
|
||||
|
||||
const selectedModel = isControlled
|
||||
@ -358,13 +381,15 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
|
||||
const buttonProviderLabel = isRuntimeScoped
|
||||
? selectedProviderChoice?.providerName ?? activeProviderName ?? t('settings.providers.officialName')
|
||||
: null
|
||||
const selectedRuntimeEffort = activeRuntimeSelection?.effortLevel
|
||||
?? selectedRuntimeModel?.defaultReasoningEffort
|
||||
?? effortLevel
|
||||
const supportedRuntimeEfforts = selectedRuntimeModel?.supportedReasoningEfforts
|
||||
const runtimeEffortOptions = supportedRuntimeEfforts?.length
|
||||
? EFFORT_OPTIONS.filter((option) => supportedRuntimeEfforts.includes(option.value))
|
||||
: EFFORT_OPTIONS.filter((option) => option.value !== 'xhigh')
|
||||
const selectedRuntimeEffort = supportedRuntimeEfforts?.length === 0
|
||||
? undefined
|
||||
: activeRuntimeSelection?.effortLevel
|
||||
?? selectedRuntimeModel?.defaultReasoningEffort
|
||||
?? effortLevel
|
||||
const runtimeEffortOptions = supportedRuntimeEfforts === undefined
|
||||
? EFFORT_OPTIONS.filter((option) => option.value !== 'xhigh')
|
||||
: EFFORT_OPTIONS.filter((option) => supportedRuntimeEfforts.includes(option.value))
|
||||
|
||||
const handleRuntimeSelect = (selection: RuntimeSelection) => {
|
||||
onRuntimeSelectionChange?.(selection)
|
||||
@ -420,11 +445,13 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
|
||||
onClick={() => {
|
||||
const supportedEfforts = model.supportedReasoningEfforts
|
||||
const explicitEffort = activeRuntimeSelection?.effortLevel
|
||||
const nextEffort = supportedEfforts?.length
|
||||
? explicitEffort && supportedEfforts.includes(explicitEffort)
|
||||
? explicitEffort
|
||||
: model.defaultReasoningEffort ?? supportedEfforts[0]
|
||||
: explicitEffort ?? effortLevel
|
||||
const nextEffort = supportedEfforts === undefined
|
||||
? explicitEffort ?? effortLevel
|
||||
: supportedEfforts.length
|
||||
? explicitEffort && supportedEfforts.includes(explicitEffort)
|
||||
? explicitEffort
|
||||
: model.defaultReasoningEffort ?? supportedEfforts[0]
|
||||
: undefined
|
||||
handleRuntimeSelect({
|
||||
providerId: choice.providerId,
|
||||
modelId: model.id,
|
||||
|
||||
101
desktop/src/components/settings/GrokOfficialLogin.test.tsx
Normal file
101
desktop/src/components/settings/GrokOfficialLogin.test.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
const { copyMock, logoutMock, startMock, statusMock } = vi.hoisted(() => ({
|
||||
copyMock: vi.fn(),
|
||||
logoutMock: vi.fn(),
|
||||
startMock: vi.fn(),
|
||||
statusMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/hahaGrokOAuth', () => ({
|
||||
hahaGrokOAuthApi: {
|
||||
start: startMock,
|
||||
status: statusMock,
|
||||
logout: logoutMock,
|
||||
successUrl: () => 'http://127.0.0.1:3456/api/haha-grok-oauth/success',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../chat/clipboard', () => ({ copyTextToClipboard: copyMock }))
|
||||
|
||||
import { GrokOfficialLogin } from './GrokOfficialLogin'
|
||||
import { useHahaGrokOAuthStore } from '../../stores/hahaGrokOAuthStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { browserHost } from '../../lib/desktopHost/browserHost'
|
||||
|
||||
const initialOAuthState = useHahaGrokOAuthStore.getState()
|
||||
|
||||
describe('GrokOfficialLogin', () => {
|
||||
beforeEach(() => {
|
||||
statusMock.mockResolvedValue({ loggedIn: false })
|
||||
startMock.mockResolvedValue({
|
||||
authorizeUrl: 'https://accounts.x.ai/oauth/authorize?state=grok-state',
|
||||
state: 'grok-state',
|
||||
})
|
||||
copyMock.mockResolvedValue(true)
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useHahaGrokOAuthStore.setState({
|
||||
...initialOAuthState,
|
||||
status: null,
|
||||
isPolling: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useHahaGrokOAuthStore.getState().stopPolling()
|
||||
useHahaGrokOAuthStore.setState(initialOAuthState)
|
||||
Reflect.deleteProperty(window, 'desktopHost')
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('keeps a copyable authorization link when opening the browser fails', async () => {
|
||||
const open = vi.fn().mockRejectedValue(new Error('shell unavailable'))
|
||||
window.desktopHost = {
|
||||
...browserHost,
|
||||
kind: 'electron',
|
||||
isDesktop: true,
|
||||
capabilities: { ...browserHost.capabilities, shell: true },
|
||||
shell: { ...browserHost.shell, open },
|
||||
}
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
render(<GrokOfficialLogin />)
|
||||
await screen.findByRole('button', { name: 'Sign in with Grok' })
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Sign in with Grok' })))
|
||||
|
||||
expect(open).toHaveBeenCalled()
|
||||
expect(screen.getByText(/Unable to open browser/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Copy authorization link' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the local success page when authorization completes', async () => {
|
||||
const open = vi.fn().mockResolvedValue(undefined)
|
||||
window.desktopHost = {
|
||||
...browserHost,
|
||||
kind: 'electron',
|
||||
isDesktop: true,
|
||||
capabilities: { ...browserHost.capabilities, shell: true },
|
||||
shell: { ...browserHost.shell, open },
|
||||
}
|
||||
|
||||
render(<GrokOfficialLogin />)
|
||||
await screen.findByRole('button', { name: 'Sign in with Grok' })
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Sign in with Grok' })))
|
||||
expect(open).toHaveBeenCalledWith(expect.stringContaining('accounts.x.ai'))
|
||||
|
||||
act(() => {
|
||||
useHahaGrokOAuthStore.setState({
|
||||
status: { loggedIn: true, expiresAt: Date.now() + 60_000, email: 'grok@example.com' },
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(open).toHaveBeenCalledWith('http://127.0.0.1:3456/api/haha-grok-oauth/success')
|
||||
})
|
||||
})
|
||||
})
|
||||
127
desktop/src/components/settings/GrokOfficialLogin.tsx
Normal file
127
desktop/src/components/settings/GrokOfficialLogin.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Copy, LogIn, LogOut } from 'lucide-react'
|
||||
import { useHahaGrokOAuthStore } from '../../stores/hahaGrokOAuthStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { copyTextToClipboard } from '../chat/clipboard'
|
||||
import { getDesktopHost } from '../../lib/desktopHost'
|
||||
import { hahaGrokOAuthApi } from '../../api/hahaGrokOAuth'
|
||||
|
||||
export function GrokOfficialLogin() {
|
||||
const t = useTranslation()
|
||||
const [manualAuthorizeUrl, setManualAuthorizeUrl] = useState<string | null>(null)
|
||||
const [isAwaitingAuthorization, setIsAwaitingAuthorization] = useState(false)
|
||||
const { status, isLoading, error, fetchStatus, login, logout, startPolling, stopPolling } =
|
||||
useHahaGrokOAuthStore()
|
||||
|
||||
useEffect(() => {
|
||||
void fetchStatus()
|
||||
return () => stopPolling()
|
||||
}, [fetchStatus, stopPolling])
|
||||
|
||||
useEffect(() => {
|
||||
if (status?.loggedIn) setManualAuthorizeUrl(null)
|
||||
}, [status?.loggedIn])
|
||||
|
||||
useEffect(() => {
|
||||
if (!status?.loggedIn || !isAwaitingAuthorization) return
|
||||
setIsAwaitingAuthorization(false)
|
||||
void getDesktopHost().shell.open(hahaGrokOAuthApi.successUrl()).catch((err) => {
|
||||
console.error('[GrokOfficialLogin] success page open failed:', err)
|
||||
})
|
||||
}, [isAwaitingAuthorization, status?.loggedIn])
|
||||
|
||||
const handleLogin = async () => {
|
||||
setManualAuthorizeUrl(null)
|
||||
try {
|
||||
const { authorizeUrl } = await login()
|
||||
setManualAuthorizeUrl(authorizeUrl)
|
||||
try {
|
||||
await getDesktopHost().shell.open(authorizeUrl)
|
||||
setManualAuthorizeUrl(null)
|
||||
setIsAwaitingAuthorization(true)
|
||||
startPolling()
|
||||
} catch (err) {
|
||||
console.error('[GrokOfficialLogin] shellOpen failed:', err)
|
||||
useHahaGrokOAuthStore.setState({
|
||||
error: t('settings.grokOfficialLogin.openBrowserFailed'),
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Store owns request errors.
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyAuthorizeUrl = async () => {
|
||||
if (!manualAuthorizeUrl) return
|
||||
if (await copyTextToClipboard(manualAuthorizeUrl)) {
|
||||
setManualAuthorizeUrl(null)
|
||||
setIsAwaitingAuthorization(true)
|
||||
useHahaGrokOAuthStore.setState({ error: null })
|
||||
startPolling()
|
||||
} else {
|
||||
useHahaGrokOAuthStore.setState({
|
||||
error: t('settings.grokOfficialLogin.copyLinkFailed'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const manualAuthorizeButton = manualAuthorizeUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyAuthorizeUrl}
|
||||
className="inline-flex items-center gap-1.5 self-start rounded-md border border-[var(--color-border-separator)] bg-[var(--color-surface)] px-3 py-1.5 text-xs transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t('settings.grokOfficialLogin.copyAuthorizeUrl')}
|
||||
</button>
|
||||
) : null
|
||||
|
||||
if (status === null) {
|
||||
return (
|
||||
<div data-testid="grok-official-login" className="flex flex-col gap-2 text-xs">
|
||||
{error ? (
|
||||
<div className="text-[var(--color-error)]">{t('settings.grokOfficialLogin.errorPrefix')}{error}</div>
|
||||
) : (
|
||||
<div className="text-[var(--color-text-tertiary)]">{t('common.loading')}</div>
|
||||
)}
|
||||
{manualAuthorizeButton}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status.loggedIn) {
|
||||
return (
|
||||
<div data-testid="grok-official-login" className="flex items-center gap-3 text-sm">
|
||||
<span className="text-[var(--color-success)]">
|
||||
{t('settings.grokOfficialLogin.loggedInPrefix')} {status.email || t('settings.grokOfficialLogin.accountUnknown')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--color-border-separator)] bg-[var(--color-surface)] px-3 py-1 text-xs transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{isLoading ? t('settings.grokOfficialLogin.logoutProcessing') : t('settings.grokOfficialLogin.logoutButton')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="grok-official-login" className="flex flex-col gap-2">
|
||||
<div className="text-sm text-[var(--color-text-secondary)]">{t('settings.grokOfficialLogin.intro')}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center gap-2 self-start rounded-md bg-[image:var(--gradient-btn-primary)] px-4 py-2 text-sm text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)] transition-opacity hover:brightness-105 disabled:opacity-50"
|
||||
>
|
||||
<LogIn className="h-4 w-4" aria-hidden="true" />
|
||||
{isLoading ? t('settings.grokOfficialLogin.loginStarting') : t('settings.grokOfficialLogin.loginButton')}
|
||||
</button>
|
||||
{error && <div className="text-xs text-[var(--color-error)]">{t('settings.grokOfficialLogin.errorPrefix')}{error}</div>}
|
||||
{manualAuthorizeButton}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
desktop/src/constants/grokOfficialProvider.ts
Normal file
23
desktop/src/constants/grokOfficialProvider.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import type { ModelInfo } from '../types/settings'
|
||||
|
||||
export const GROK_OFFICIAL_PROVIDER_ID = 'grok-official'
|
||||
export const GROK_OFFICIAL_DEFAULT_MODEL_ID = 'grok-4.5'
|
||||
export const GROK_OFFICIAL_PROVIDER_NAME = 'Grok Official'
|
||||
|
||||
export const GROK_OFFICIAL_MODELS: ModelInfo[] = [
|
||||
{
|
||||
id: GROK_OFFICIAL_DEFAULT_MODEL_ID,
|
||||
name: 'Grok 4.5',
|
||||
description: 'Grok frontier text model',
|
||||
context: '500000',
|
||||
defaultReasoningEffort: 'high',
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high'],
|
||||
},
|
||||
{
|
||||
id: 'grok-composer-2.5-fast',
|
||||
name: 'Composer 2.5',
|
||||
description: 'Grok coding model',
|
||||
context: '200000',
|
||||
supportedReasoningEfforts: [],
|
||||
},
|
||||
]
|
||||
@ -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'
|
||||
|
||||
@ -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)',
|
||||
|
||||
@ -432,6 +432,17 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'settings.chatgptOfficialLogin.copyAuthorizeUrl': '承認リンクをコピー',
|
||||
'settings.chatgptOfficialLogin.copyLinkFailed': '承認リンクをコピーできません。',
|
||||
'settings.chatgptOfficialLogin.errorPrefix': 'ChatGPT OAuth エラー: ',
|
||||
'settings.grokOfficialLogin.intro': 'デスクトップセッションで公式 Grok モデルを使用するには、Grok でサインインしてください。',
|
||||
'settings.grokOfficialLogin.loginButton': 'Grok でサインイン',
|
||||
'settings.grokOfficialLogin.loginStarting': 'サインインを開始中...',
|
||||
'settings.grokOfficialLogin.logoutButton': 'サインアウト',
|
||||
'settings.grokOfficialLogin.logoutProcessing': 'サインアウト中...',
|
||||
'settings.grokOfficialLogin.loggedInPrefix': 'サインイン中:',
|
||||
'settings.grokOfficialLogin.accountUnknown': 'Grok アカウント',
|
||||
'settings.grokOfficialLogin.openBrowserFailed': 'ブラウザを開けません。承認リンクをコピーして手動で開いてください。',
|
||||
'settings.grokOfficialLogin.copyAuthorizeUrl': '承認リンクをコピー',
|
||||
'settings.grokOfficialLogin.copyLinkFailed': '承認リンクをコピーできません。',
|
||||
'settings.grokOfficialLogin.errorPrefix': 'Grok OAuth エラー: ',
|
||||
|
||||
// Settings > Providers
|
||||
'settings.providers.title': 'プロバイダー',
|
||||
@ -441,6 +452,8 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'settings.providers.officialDesc': 'Anthropic ネイティブ — API キー不要',
|
||||
'settings.providers.openaiOfficialName': 'ChatGPT 公式',
|
||||
'settings.providers.openaiOfficialDesc': 'ChatGPT アカウントによる OpenAI OAuth — API キー不要',
|
||||
'settings.providers.grokOfficialName': 'Grok 公式',
|
||||
'settings.providers.grokOfficialDesc': 'xAI アカウントによる公式 Grok OAuth — API キー不要',
|
||||
'settings.providers.connected': '接続済み ({latency}ms)',
|
||||
'settings.providers.failed': '失敗: {error}',
|
||||
'settings.providers.connectivityOk': '① 接続 ({latency}ms)',
|
||||
|
||||
@ -432,6 +432,17 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'settings.chatgptOfficialLogin.copyAuthorizeUrl': '승인 링크 복사',
|
||||
'settings.chatgptOfficialLogin.copyLinkFailed': '승인 링크를 복사할 수 없습니다.',
|
||||
'settings.chatgptOfficialLogin.errorPrefix': 'ChatGPT OAuth 오류: ',
|
||||
'settings.grokOfficialLogin.intro': '데스크톱 세션에서 공식 Grok 모델을 사용하려면 Grok으로 로그인하세요.',
|
||||
'settings.grokOfficialLogin.loginButton': 'Grok으로 로그인',
|
||||
'settings.grokOfficialLogin.loginStarting': '로그인 시작 중...',
|
||||
'settings.grokOfficialLogin.logoutButton': '로그아웃',
|
||||
'settings.grokOfficialLogin.logoutProcessing': '로그아웃 중...',
|
||||
'settings.grokOfficialLogin.loggedInPrefix': '로그인 계정:',
|
||||
'settings.grokOfficialLogin.accountUnknown': 'Grok 계정',
|
||||
'settings.grokOfficialLogin.openBrowserFailed': '브라우저를 열 수 없습니다. 승인 링크를 복사하여 수동으로 여세요.',
|
||||
'settings.grokOfficialLogin.copyAuthorizeUrl': '승인 링크 복사',
|
||||
'settings.grokOfficialLogin.copyLinkFailed': '승인 링크를 복사할 수 없습니다.',
|
||||
'settings.grokOfficialLogin.errorPrefix': 'Grok OAuth 오류: ',
|
||||
|
||||
// Settings > Providers
|
||||
'settings.providers.title': '공급자',
|
||||
@ -441,6 +452,8 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'settings.providers.officialDesc': 'Anthropic 네이티브 — API 키 불필요',
|
||||
'settings.providers.openaiOfficialName': 'ChatGPT 공식',
|
||||
'settings.providers.openaiOfficialDesc': 'ChatGPT 계정을 통한 OpenAI OAuth — API 키 불필요',
|
||||
'settings.providers.grokOfficialName': 'Grok 공식',
|
||||
'settings.providers.grokOfficialDesc': 'xAI 계정을 통한 공식 Grok OAuth — API 키 불필요',
|
||||
'settings.providers.connected': '연결됨 ({latency}ms)',
|
||||
'settings.providers.failed': '실패: {error}',
|
||||
'settings.providers.connectivityOk': '① 연결 ({latency}ms)',
|
||||
|
||||
@ -432,6 +432,17 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.chatgptOfficialLogin.copyAuthorizeUrl': '複製授權連結',
|
||||
'settings.chatgptOfficialLogin.copyLinkFailed': '無法複製授權連結。',
|
||||
'settings.chatgptOfficialLogin.errorPrefix': 'ChatGPT OAuth 錯誤:',
|
||||
'settings.grokOfficialLogin.intro': '登入 Grok 後,即可在桌面端會話中使用 Grok 官方模型。',
|
||||
'settings.grokOfficialLogin.loginButton': '登入 Grok',
|
||||
'settings.grokOfficialLogin.loginStarting': '正在啟動登入...',
|
||||
'settings.grokOfficialLogin.logoutButton': '退出登入',
|
||||
'settings.grokOfficialLogin.logoutProcessing': '正在退出...',
|
||||
'settings.grokOfficialLogin.loggedInPrefix': '已登入',
|
||||
'settings.grokOfficialLogin.accountUnknown': 'Grok 賬號',
|
||||
'settings.grokOfficialLogin.openBrowserFailed': '無法開啟瀏覽器。請複製授權連結並手動開啟。',
|
||||
'settings.grokOfficialLogin.copyAuthorizeUrl': '複製授權連結',
|
||||
'settings.grokOfficialLogin.copyLinkFailed': '無法複製授權連結。',
|
||||
'settings.grokOfficialLogin.errorPrefix': 'Grok OAuth 錯誤:',
|
||||
|
||||
// Settings > Providers
|
||||
'settings.providers.title': '服務商',
|
||||
@ -441,6 +452,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.providers.officialDesc': 'Anthropic 原生接入 — 無需 API 金鑰',
|
||||
'settings.providers.openaiOfficialName': 'ChatGPT 官方',
|
||||
'settings.providers.openaiOfficialDesc': '透過 ChatGPT 賬號完成 OpenAI OAuth — 無需 API 金鑰',
|
||||
'settings.providers.grokOfficialName': 'Grok 官方',
|
||||
'settings.providers.grokOfficialDesc': '透過 xAI 賬號完成 Grok 官方 OAuth — 無需 API 金鑰',
|
||||
'settings.providers.connected': '已連線 ({latency}ms)',
|
||||
'settings.providers.failed': '失敗: {error}',
|
||||
'settings.providers.connectivityOk': '① 連通 ({latency}ms)',
|
||||
|
||||
@ -432,6 +432,17 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.chatgptOfficialLogin.copyAuthorizeUrl': '复制授权链接',
|
||||
'settings.chatgptOfficialLogin.copyLinkFailed': '无法复制授权链接。',
|
||||
'settings.chatgptOfficialLogin.errorPrefix': 'ChatGPT OAuth 错误:',
|
||||
'settings.grokOfficialLogin.intro': '登录 Grok 后,即可在桌面端会话中使用 Grok 官方模型。',
|
||||
'settings.grokOfficialLogin.loginButton': '登录 Grok',
|
||||
'settings.grokOfficialLogin.loginStarting': '正在启动登录...',
|
||||
'settings.grokOfficialLogin.logoutButton': '退出登录',
|
||||
'settings.grokOfficialLogin.logoutProcessing': '正在退出...',
|
||||
'settings.grokOfficialLogin.loggedInPrefix': '已登录',
|
||||
'settings.grokOfficialLogin.accountUnknown': 'Grok 账号',
|
||||
'settings.grokOfficialLogin.openBrowserFailed': '无法打开浏览器。请复制授权链接并手动打开。',
|
||||
'settings.grokOfficialLogin.copyAuthorizeUrl': '复制授权链接',
|
||||
'settings.grokOfficialLogin.copyLinkFailed': '无法复制授权链接。',
|
||||
'settings.grokOfficialLogin.errorPrefix': 'Grok OAuth 错误:',
|
||||
|
||||
// Settings > Providers
|
||||
'settings.providers.title': '服务商',
|
||||
@ -441,6 +452,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.providers.officialDesc': 'Anthropic 原生接入 — 无需 API 密钥',
|
||||
'settings.providers.openaiOfficialName': 'ChatGPT 官方',
|
||||
'settings.providers.openaiOfficialDesc': '通过 ChatGPT 账号完成 OpenAI OAuth — 无需 API 密钥',
|
||||
'settings.providers.grokOfficialName': 'Grok 官方',
|
||||
'settings.providers.grokOfficialDesc': '通过 xAI 账号完成 Grok 官方 OAuth — 无需 API 密钥',
|
||||
'settings.providers.connected': '已连接 ({latency}ms)',
|
||||
'settings.providers.failed': '失败: {error}',
|
||||
'settings.providers.connectivityOk': '① 连通 ({latency}ms)',
|
||||
|
||||
@ -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
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
|
||||
@ -52,11 +52,13 @@ import { MemorySettings } from './MemorySettings'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
|
||||
import { ChatGPTOfficialLogin } from '../components/settings/ChatGPTOfficialLogin'
|
||||
import { GrokOfficialLogin } from '../components/settings/GrokOfficialLogin'
|
||||
import {
|
||||
BUILT_IN_PROVIDER_IDS,
|
||||
CLAUDE_OFFICIAL_PROVIDER_ID,
|
||||
OPENAI_OFFICIAL_PROVIDER_ID,
|
||||
} from '../constants/openaiOfficialProvider'
|
||||
import { GROK_OFFICIAL_PROVIDER_ID } from '../constants/grokOfficialProvider'
|
||||
import { useUpdateStore } from '../stores/updateStore'
|
||||
import { getBaseUrl } from '../api/client'
|
||||
import { formatBytes } from '../lib/formatBytes'
|
||||
@ -279,6 +281,7 @@ function TabButton({ icon, label, active, onClick }: { icon: string; label: stri
|
||||
type ProviderListItem =
|
||||
| { id: typeof CLAUDE_OFFICIAL_PROVIDER_ID; kind: 'claude-official' }
|
||||
| { id: typeof OPENAI_OFFICIAL_PROVIDER_ID; kind: 'openai-official' }
|
||||
| { id: typeof GROK_OFFICIAL_PROVIDER_ID; kind: 'grok-official' }
|
||||
| { id: string; kind: 'saved'; provider: SavedProvider }
|
||||
|
||||
function defaultProviderOrder(providers: SavedProvider[]): string[] {
|
||||
@ -325,6 +328,7 @@ function buildProviderListItems(
|
||||
const items = new Map<string, ProviderListItem>([
|
||||
[CLAUDE_OFFICIAL_PROVIDER_ID, { id: CLAUDE_OFFICIAL_PROVIDER_ID, kind: 'claude-official' }],
|
||||
[OPENAI_OFFICIAL_PROVIDER_ID, { id: OPENAI_OFFICIAL_PROVIDER_ID, kind: 'openai-official' }],
|
||||
[GROK_OFFICIAL_PROVIDER_ID, { id: GROK_OFFICIAL_PROVIDER_ID, kind: 'grok-official' }],
|
||||
...savedItems,
|
||||
])
|
||||
|
||||
@ -339,6 +343,8 @@ function providerItemTestId(item: ProviderListItem): string {
|
||||
return 'claude-official-provider'
|
||||
case 'openai-official':
|
||||
return 'openai-official-provider'
|
||||
case 'grok-official':
|
||||
return 'grok-official-provider'
|
||||
case 'saved':
|
||||
return `provider-${item.provider.id}`
|
||||
}
|
||||
@ -444,6 +450,7 @@ function ProviderSettings() {
|
||||
|
||||
const isClaudeOfficialActive = hasLoadedProviders && activeId === null
|
||||
const isOpenAIOfficialActive = hasLoadedProviders && activeId === OPENAI_OFFICIAL_PROVIDER_ID
|
||||
const isGrokOfficialActive = hasLoadedProviders && activeId === GROK_OFFICIAL_PROVIDER_ID
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
@ -513,6 +520,28 @@ function ProviderSettings() {
|
||||
)
|
||||
}
|
||||
|
||||
if (item.kind === 'grok-official') {
|
||||
return (
|
||||
<SortableProviderCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
isActive={isGrokOfficialActive}
|
||||
dragLabel={t('settings.providers.dragToReorder')}
|
||||
onActivate={!isGrokOfficialActive ? () => handleActivate(GROK_OFFICIAL_PROVIDER_ID) : undefined}
|
||||
title={t('settings.providers.grokOfficialName')}
|
||||
subtitle={t('settings.providers.grokOfficialDesc')}
|
||||
badges={isGrokOfficialActive ? (
|
||||
<span className="rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/12 px-1.5 py-0.5 text-[10px] font-bold leading-none text-[var(--color-brand)]">{t('settings.providers.default')}</span>
|
||||
) : null}
|
||||
details={isGrokOfficialActive ? (
|
||||
<div className="border-t border-[var(--color-border-separator)] px-4 pb-4 pt-3">
|
||||
<GrokOfficialLogin />
|
||||
</div>
|
||||
) : null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const provider = item.provider
|
||||
const isActive = activeId === provider.id
|
||||
const test = testResults[provider.id]
|
||||
|
||||
72
desktop/src/stores/hahaGrokOAuthStore.test.ts
Normal file
72
desktop/src/stores/hahaGrokOAuthStore.test.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { startMock, statusMock, logoutMock } = vi.hoisted(() => ({
|
||||
startMock: vi.fn(),
|
||||
statusMock: vi.fn(),
|
||||
logoutMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../api/hahaGrokOAuth', () => ({
|
||||
hahaGrokOAuthApi: {
|
||||
start: startMock,
|
||||
status: statusMock,
|
||||
logout: logoutMock,
|
||||
},
|
||||
}))
|
||||
|
||||
import { useHahaGrokOAuthStore } from './hahaGrokOAuthStore'
|
||||
|
||||
const initialState = useHahaGrokOAuthStore.getState()
|
||||
|
||||
describe('hahaGrokOAuthStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
startMock.mockReset()
|
||||
statusMock.mockReset()
|
||||
logoutMock.mockReset()
|
||||
useHahaGrokOAuthStore.setState({
|
||||
...initialState,
|
||||
status: null,
|
||||
isPolling: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useHahaGrokOAuthStore.getState().stopPolling()
|
||||
useHahaGrokOAuthStore.setState(initialState)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('returns the authorization URL without polling before the browser opens', async () => {
|
||||
startMock.mockResolvedValue({
|
||||
authorizeUrl: 'https://accounts.x.ai/oauth/authorize?state=grok-state',
|
||||
state: 'grok-state',
|
||||
})
|
||||
|
||||
const result = await useHahaGrokOAuthStore.getState().login()
|
||||
|
||||
expect(result.authorizeUrl).toContain('state=grok-state')
|
||||
expect(useHahaGrokOAuthStore.getState().isPolling).toBe(false)
|
||||
})
|
||||
|
||||
it('stops polling after Grok OAuth becomes logged in', async () => {
|
||||
statusMock
|
||||
.mockResolvedValueOnce({ loggedIn: false })
|
||||
.mockResolvedValueOnce({
|
||||
loggedIn: true,
|
||||
expiresAt: Date.now() + 60_000,
|
||||
email: 'grok@example.com',
|
||||
})
|
||||
|
||||
useHahaGrokOAuthStore.getState().startPolling()
|
||||
await vi.advanceTimersByTimeAsync(4_000)
|
||||
|
||||
expect(useHahaGrokOAuthStore.getState().status).toMatchObject({
|
||||
loggedIn: true,
|
||||
email: 'grok@example.com',
|
||||
})
|
||||
expect(useHahaGrokOAuthStore.getState().isPolling).toBe(false)
|
||||
})
|
||||
})
|
||||
91
desktop/src/stores/hahaGrokOAuthStore.ts
Normal file
91
desktop/src/stores/hahaGrokOAuthStore.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { create } from 'zustand'
|
||||
import { hahaGrokOAuthApi, type HahaGrokOAuthStatus } from '../api/hahaGrokOAuth'
|
||||
|
||||
const POLL_INTERVAL_MS = 2_000
|
||||
|
||||
type HahaGrokOAuthState = {
|
||||
status: HahaGrokOAuthStatus | null
|
||||
isPolling: boolean
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
fetchStatus: () => Promise<void>
|
||||
login: () => Promise<{ authorizeUrl: string }>
|
||||
logout: () => Promise<void>
|
||||
startPolling: () => void
|
||||
stopPolling: () => void
|
||||
}
|
||||
|
||||
export const useHahaGrokOAuthStore = create<HahaGrokOAuthState>((set, get) => {
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
return {
|
||||
status: null,
|
||||
isPolling: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchStatus: async () => {
|
||||
try {
|
||||
set({ status: await hahaGrokOAuthApi.status(), error: null })
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
},
|
||||
|
||||
login: async () => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const result = await hahaGrokOAuthApi.start()
|
||||
set({ isLoading: false })
|
||||
return { authorizeUrl: result.authorizeUrl }
|
||||
} catch (err) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
get().stopPolling()
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
await hahaGrokOAuthApi.logout()
|
||||
set({ status: { loggedIn: false }, isLoading: false })
|
||||
} catch (err) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
startPolling: () => {
|
||||
if (pollTimer) return
|
||||
set({ isPolling: true })
|
||||
|
||||
const scheduleNext = () => {
|
||||
pollTimer = setTimeout(async () => {
|
||||
pollTimer = null
|
||||
await get().fetchStatus()
|
||||
if (get().status?.loggedIn) {
|
||||
get().stopPolling()
|
||||
} else if (get().isPolling) {
|
||||
scheduleNext()
|
||||
}
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
scheduleNext()
|
||||
},
|
||||
|
||||
stopPolling: () => {
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
set({ isPolling: false })
|
||||
},
|
||||
}
|
||||
})
|
||||
@ -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'])
|
||||
})
|
||||
|
||||
|
||||
@ -11,6 +11,10 @@ import {
|
||||
OPENAI_OFFICIAL_DEFAULT_MODEL_ID,
|
||||
OPENAI_OFFICIAL_PROVIDER_ID,
|
||||
} from '../constants/openaiOfficialProvider'
|
||||
import {
|
||||
GROK_OFFICIAL_DEFAULT_MODEL_ID,
|
||||
GROK_OFFICIAL_PROVIDER_ID,
|
||||
} from '../constants/grokOfficialProvider'
|
||||
import type {
|
||||
SavedProvider,
|
||||
CreateProviderInput,
|
||||
@ -268,6 +272,11 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
|
||||
await settings.fetchAll()
|
||||
return
|
||||
}
|
||||
if (id === GROK_OFFICIAL_PROVIDER_ID) {
|
||||
await settings.setModel(GROK_OFFICIAL_DEFAULT_MODEL_ID)
|
||||
await settings.fetchAll()
|
||||
return
|
||||
}
|
||||
|
||||
const provider = get().providers.find((p) => p.id === id)
|
||||
if (!provider) return
|
||||
|
||||
64
desktop/src/stores/sessionRuntimeStore.test.ts
Normal file
64
desktop/src/stores/sessionRuntimeStore.test.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionListItem } from '../types/session'
|
||||
import { useSessionRuntimeStore } from './sessionRuntimeStore'
|
||||
|
||||
const EXPECTED_GROK_SELECTION = {
|
||||
providerId: 'grok-official',
|
||||
modelId: 'grok-4.5',
|
||||
effortLevel: 'high',
|
||||
}
|
||||
|
||||
describe('sessionRuntimeStore Grok runtime cleanup', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
useSessionRuntimeStore.setState({ selections: {} })
|
||||
})
|
||||
|
||||
it('discards retired Grok selections before persisting them', () => {
|
||||
useSessionRuntimeStore.getState().setSelection('session-grok', {
|
||||
providerId: 'grok-official',
|
||||
modelId: 'grok-build',
|
||||
effortLevel: 'max',
|
||||
})
|
||||
|
||||
expect(useSessionRuntimeStore.getState().selections['session-grok']).toEqual(
|
||||
EXPECTED_GROK_SELECTION,
|
||||
)
|
||||
expect(JSON.parse(localStorage.getItem('cc-haha-session-runtime')!)).toEqual({
|
||||
'session-grok': EXPECTED_GROK_SELECTION,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not let retired Grok session metadata restore the removed model', () => {
|
||||
useSessionRuntimeStore.getState().syncFromSessions([{
|
||||
id: 'session-restored-grok',
|
||||
runtimeProviderId: 'grok-official',
|
||||
runtimeModelId: 'grok-build',
|
||||
effortLevel: 'max',
|
||||
} as SessionListItem])
|
||||
|
||||
expect(useSessionRuntimeStore.getState().selections['session-restored-grok']).toEqual(
|
||||
EXPECTED_GROK_SELECTION,
|
||||
)
|
||||
})
|
||||
|
||||
it('cleans a retired Grok selection loaded from localStorage', async () => {
|
||||
localStorage.setItem('cc-haha-session-runtime', JSON.stringify({
|
||||
'session-loaded-grok': {
|
||||
providerId: 'grok-official',
|
||||
modelId: 'grok-build',
|
||||
effortLevel: 'max',
|
||||
},
|
||||
}))
|
||||
vi.resetModules()
|
||||
|
||||
const { useSessionRuntimeStore: loadedStore } = await import('./sessionRuntimeStore')
|
||||
|
||||
expect(loadedStore.getState().selections['session-loaded-grok']).toEqual(
|
||||
EXPECTED_GROK_SELECTION,
|
||||
)
|
||||
expect(JSON.parse(localStorage.getItem('cc-haha-session-runtime')!)).toEqual({
|
||||
'session-loaded-grok': EXPECTED_GROK_SELECTION,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,8 +1,20 @@
|
||||
import { create } from 'zustand'
|
||||
import type { RuntimeSelection } from '../types/runtime'
|
||||
import type { SessionListItem } from '../types/session'
|
||||
import {
|
||||
GROK_OFFICIAL_DEFAULT_MODEL_ID,
|
||||
GROK_OFFICIAL_MODELS,
|
||||
GROK_OFFICIAL_PROVIDER_ID,
|
||||
} from '../constants/grokOfficialProvider'
|
||||
|
||||
const STORAGE_KEY = 'cc-haha-session-runtime'
|
||||
const RETIRED_GROK_MODEL_IDS = new Set([
|
||||
'grok-build',
|
||||
'grok-build-0.1',
|
||||
'grok-4.3',
|
||||
'grok-4.20-reasoning',
|
||||
'grok-4.20-non-reasoning',
|
||||
])
|
||||
|
||||
export const DRAFT_RUNTIME_SELECTION_KEY = '__draft__'
|
||||
|
||||
@ -14,13 +26,50 @@ type SessionRuntimeStore = {
|
||||
syncFromSessions: (sessions: SessionListItem[]) => void
|
||||
}
|
||||
|
||||
function normalizeSelection(selection: RuntimeSelection): RuntimeSelection {
|
||||
if (
|
||||
selection.providerId !== GROK_OFFICIAL_PROVIDER_ID ||
|
||||
!RETIRED_GROK_MODEL_IDS.has(selection.modelId)
|
||||
) {
|
||||
return selection
|
||||
}
|
||||
|
||||
const fallback = GROK_OFFICIAL_MODELS.find(
|
||||
(model) => model.id === GROK_OFFICIAL_DEFAULT_MODEL_ID,
|
||||
)
|
||||
return {
|
||||
providerId: GROK_OFFICIAL_PROVIDER_ID,
|
||||
modelId: GROK_OFFICIAL_DEFAULT_MODEL_ID,
|
||||
...(fallback?.defaultReasoningEffort
|
||||
? { effortLevel: fallback.defaultReasoningEffort }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSelections(
|
||||
selections: Record<string, RuntimeSelection>,
|
||||
): { selections: Record<string, RuntimeSelection>; changed: boolean } {
|
||||
let changed = false
|
||||
const normalized = Object.fromEntries(
|
||||
Object.entries(selections).map(([key, selection]) => {
|
||||
const next = normalizeSelection(selection)
|
||||
if (next !== selection) changed = true
|
||||
return [key, next]
|
||||
}),
|
||||
)
|
||||
return { selections: normalized, changed }
|
||||
}
|
||||
|
||||
function loadSelections(): Record<string, RuntimeSelection> {
|
||||
if (typeof localStorage === 'undefined') return {}
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return {}
|
||||
const parsed = JSON.parse(raw) as Record<string, RuntimeSelection>
|
||||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||
if (!parsed || typeof parsed !== 'object') return {}
|
||||
const normalized = normalizeSelections(parsed)
|
||||
if (normalized.changed) persistSelections(normalized.selections)
|
||||
return normalized.selections
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
@ -42,7 +91,7 @@ export const useSessionRuntimeStore = create<SessionRuntimeStore>((set) => ({
|
||||
set((state) => {
|
||||
const selections = {
|
||||
...state.selections,
|
||||
[key]: selection,
|
||||
[key]: normalizeSelection(selection),
|
||||
}
|
||||
persistSelections(selections)
|
||||
return { selections }
|
||||
@ -74,11 +123,11 @@ export const useSessionRuntimeStore = create<SessionRuntimeStore>((set) => ({
|
||||
let selections = state.selections
|
||||
for (const session of sessions) {
|
||||
if (!session.runtimeModelId || session.runtimeProviderId === undefined) continue
|
||||
const selection: RuntimeSelection = {
|
||||
const selection = normalizeSelection({
|
||||
providerId: session.runtimeProviderId,
|
||||
modelId: session.runtimeModelId,
|
||||
...(session.effortLevel ? { effortLevel: session.effortLevel } : {}),
|
||||
}
|
||||
})
|
||||
const current = selections[session.id]
|
||||
if (
|
||||
current?.providerId === selection.providerId &&
|
||||
|
||||
@ -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
|
||||
|
||||
@ -750,6 +750,25 @@ describe('ConversationService', () => {
|
||||
expect(env.ANTHROPIC_BASE_URL).toBeUndefined()
|
||||
})
|
||||
|
||||
test('buildChildEnv injects isolated Grok Official runtime env for session-scoped selection', async () => {
|
||||
const service = new ConversationService() as any
|
||||
const env = (await service.buildChildEnv('/tmp', undefined, {
|
||||
providerId: 'grok-official',
|
||||
model: 'grok-4.5',
|
||||
})) as Record<string, string>
|
||||
|
||||
expect(env.CC_HAHA_GROK_OAUTH_PROVIDER).toBe('1')
|
||||
expect(env.GROK_OAUTH_FILE).toBe(path.join(tmpDir, 'cc-haha', 'grok-oauth.json'))
|
||||
expect(env.ANTHROPIC_MODEL).toBe('grok-4.5')
|
||||
expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1')
|
||||
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()
|
||||
expect(env.CC_HAHA_OPENAI_OAUTH_PROVIDER).toBeUndefined()
|
||||
expect(env.OPENAI_CODEX_OAUTH_FILE).toBeUndefined()
|
||||
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
|
||||
expect(env.ANTHROPIC_BASE_URL).toBeUndefined()
|
||||
})
|
||||
|
||||
test('buildChildEnv passes OpenAI-native effort without leaking Claude effort state', async () => {
|
||||
const originalEffort = process.env.CC_HAHA_OPENAI_REASONING_EFFORT
|
||||
process.env.CC_HAHA_OPENAI_REASONING_EFFORT = 'stale-parent-effort'
|
||||
|
||||
61
src/server/__tests__/haha-grok-oauth-api.test.ts
Normal file
61
src/server/__tests__/haha-grok-oauth-api.test.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import { handleHahaGrokOAuthApi } from '../api/haha-grok-oauth.js'
|
||||
import { hahaGrokOAuthService } from '../services/hahaGrokOAuthService.js'
|
||||
|
||||
let tempDir: string
|
||||
let previousConfigDir: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'haha-grok-oauth-api-'))
|
||||
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
process.env.CLAUDE_CONFIG_DIR = tempDir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
hahaGrokOAuthService.dispose()
|
||||
if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
|
||||
else process.env.CLAUDE_CONFIG_DIR = previousConfigDir
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('Haha Grok OAuth API', () => {
|
||||
test('serves a clear local success page after browser authorization', async () => {
|
||||
const response = await handleHahaGrokOAuthApi(
|
||||
new Request('http://localhost/api/haha-grok-oauth/success'),
|
||||
new URL('http://localhost/api/haha-grok-oauth/success'),
|
||||
['api', 'haha-grok-oauth', 'success'],
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toContain('text/html')
|
||||
expect(await response.text()).toContain('Grok Login Successful')
|
||||
})
|
||||
|
||||
test('returns status without exposing token material and logs out', async () => {
|
||||
await hahaGrokOAuthService.saveTokens({
|
||||
accessToken: 'secret-access',
|
||||
refreshToken: 'secret-refresh',
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
email: 'user@example.com',
|
||||
})
|
||||
const statusResponse = await handleHahaGrokOAuthApi(
|
||||
new Request('http://localhost/api/haha-grok-oauth'),
|
||||
new URL('http://localhost/api/haha-grok-oauth'),
|
||||
['api', 'haha-grok-oauth'],
|
||||
)
|
||||
const statusText = await statusResponse.text()
|
||||
expect(statusText).toContain('user@example.com')
|
||||
expect(statusText).not.toContain('secret-access')
|
||||
expect(statusText).not.toContain('secret-refresh')
|
||||
|
||||
const logoutResponse = await handleHahaGrokOAuthApi(
|
||||
new Request('http://localhost/api/haha-grok-oauth', { method: 'DELETE' }),
|
||||
new URL('http://localhost/api/haha-grok-oauth'),
|
||||
['api', 'haha-grok-oauth'],
|
||||
)
|
||||
expect(logoutResponse.status).toBe(200)
|
||||
await expect(hahaGrokOAuthService.loadTokens()).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
87
src/server/__tests__/haha-grok-oauth-service.test.ts
Normal file
87
src/server/__tests__/haha-grok-oauth-service.test.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import {
|
||||
HahaGrokOAuthService,
|
||||
getHahaGrokOAuthFilePath,
|
||||
} from '../services/hahaGrokOAuthService.js'
|
||||
|
||||
let tempDir: string
|
||||
let previousConfigDir: string | undefined
|
||||
let previousFetch: typeof globalThis.fetch
|
||||
let service: HahaGrokOAuthService
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'haha-grok-oauth-test-'))
|
||||
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
process.env.CLAUDE_CONFIG_DIR = tempDir
|
||||
previousFetch = globalThis.fetch
|
||||
service = new HahaGrokOAuthService()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
service.dispose()
|
||||
globalThis.fetch = previousFetch
|
||||
if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
|
||||
else process.env.CLAUDE_CONFIG_DIR = previousConfigDir
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('HahaGrokOAuthService', () => {
|
||||
test('starts a random 127.0.0.1 PKCE callback and persists exchanged tokens', async () => {
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
expect(init?.method).toBe('POST')
|
||||
const body = new URLSearchParams(String(init?.body))
|
||||
expect(body.get('grant_type')).toBe('authorization_code')
|
||||
expect(body.get('client_secret')).toBeNull()
|
||||
return Response.json({
|
||||
access_token: 'grok-access',
|
||||
refresh_token: 'grok-refresh',
|
||||
expires_in: 3600,
|
||||
})
|
||||
}) as typeof fetch
|
||||
|
||||
const session = await service.startSession()
|
||||
const authorizeUrl = new URL(session.authorizeUrl)
|
||||
const redirectUri = new URL(authorizeUrl.searchParams.get('redirect_uri')!)
|
||||
expect(redirectUri.hostname).toBe('127.0.0.1')
|
||||
expect(Number(redirectUri.port)).toBeGreaterThan(0)
|
||||
expect(authorizeUrl.searchParams.get('state')).toBe(session.state)
|
||||
expect(authorizeUrl.searchParams.get('code_challenge_method')).toBe('S256')
|
||||
|
||||
const callback = new URL(session.redirectUri)
|
||||
callback.searchParams.set('code', 'code')
|
||||
callback.searchParams.set('state', session.state)
|
||||
const callbackResponse = await previousFetch(callback)
|
||||
expect(callbackResponse.status).toBe(200)
|
||||
expect(await service.loadTokens()).toMatchObject({
|
||||
accessToken: 'grok-access',
|
||||
refreshToken: 'grok-refresh',
|
||||
})
|
||||
expect((await fs.stat(getHahaGrokOAuthFilePath())).mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
test('refreshes an expiring token and preserves an unrotated refresh token', async () => {
|
||||
await service.saveTokens({
|
||||
accessToken: 'old-access',
|
||||
refreshToken: 'old-refresh',
|
||||
expiresAt: Date.now() - 1,
|
||||
email: 'user@example.com',
|
||||
})
|
||||
service.setRefreshFn(async () => ({
|
||||
access_token: 'new-access',
|
||||
expires_in: 3600,
|
||||
}))
|
||||
|
||||
await expect(service.ensureFreshTokens()).resolves.toMatchObject({
|
||||
accessToken: 'new-access',
|
||||
refreshToken: 'old-refresh',
|
||||
email: 'user@example.com',
|
||||
})
|
||||
await expect(service.loadTokens()).resolves.toMatchObject({
|
||||
accessToken: 'new-access',
|
||||
refreshToken: 'old-refresh',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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([])
|
||||
})
|
||||
|
||||
|
||||
@ -9,6 +9,8 @@ import {
|
||||
} from '../services/providerRuntimeEnv.js'
|
||||
|
||||
let tmpDir: string
|
||||
let originalConfigDir: string | undefined
|
||||
let originalHome: string | undefined
|
||||
|
||||
async function writeJson(filePath: string, value: unknown): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||
@ -18,12 +20,52 @@ async function writeJson(filePath: string, value: unknown): Promise<void> {
|
||||
describe('providerRuntimeEnv', () => {
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'provider-runtime-env-'))
|
||||
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
originalHome = process.env.HOME
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
process.env.HOME = tmpDir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
else delete process.env.CLAUDE_CONFIG_DIR
|
||||
if (originalHome !== undefined) process.env.HOME = originalHome
|
||||
else delete process.env.HOME
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('normalizes and preserves Grok Official as the active runtime provider', async () => {
|
||||
await writeJson(path.join(tmpDir, 'cc-haha', 'providers.json'), {
|
||||
activeId: 'grok-official',
|
||||
providers: [],
|
||||
providerOrder: ['claude-official', 'openai-official'],
|
||||
})
|
||||
|
||||
const env = mergeActiveProviderManagedEnv(
|
||||
{
|
||||
CC_HAHA_OPENAI_OAUTH_PROVIDER: '1',
|
||||
OPENAI_CODEX_OAUTH_FILE: path.join(tmpDir, 'stale-openai-oauth.json'),
|
||||
ANTHROPIC_MODEL: 'stale-openai-model',
|
||||
DISABLE_AUTOUPDATER: '1',
|
||||
},
|
||||
tmpDir,
|
||||
)
|
||||
|
||||
expect(env).toMatchObject({
|
||||
CC_HAHA_GROK_OAUTH_PROVIDER: '1',
|
||||
GROK_OAUTH_FILE: path.join(tmpDir, 'cc-haha', 'grok-oauth.json'),
|
||||
ANTHROPIC_MODEL: 'grok-4.5',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'grok-4.5',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'grok-4.5',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'grok-4.5',
|
||||
DISABLE_AUTOUPDATER: '1',
|
||||
})
|
||||
expect(env.CC_HAHA_OPENAI_OAUTH_PROVIDER).toBeUndefined()
|
||||
expect(env.OPENAI_CODEX_OAUTH_FILE).toBeUndefined()
|
||||
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
|
||||
})
|
||||
|
||||
test('derives native Anthropic provider env from the active provider index', async () => {
|
||||
await writeJson(path.join(tmpDir, 'cc-haha', 'providers.json'), {
|
||||
activeId: 'provider-1',
|
||||
|
||||
@ -16,11 +16,14 @@ import type { CreateProviderInput } from '../types/provider.js'
|
||||
|
||||
let tmpDir: string
|
||||
let originalConfigDir: string | undefined
|
||||
let originalHome: string | undefined
|
||||
|
||||
async function setup() {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'provider-test-'))
|
||||
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
originalHome = process.env.HOME
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
process.env.HOME = tmpDir
|
||||
clearTraceCaptureStateForTests()
|
||||
}
|
||||
|
||||
@ -31,6 +34,11 @@ async function teardown() {
|
||||
} else {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
}
|
||||
if (originalHome !== undefined) {
|
||||
process.env.HOME = originalHome
|
||||
} else {
|
||||
delete process.env.HOME
|
||||
}
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@ -98,7 +106,7 @@ describe('ProviderService', () => {
|
||||
expect(result).toEqual({
|
||||
providers: [],
|
||||
activeId: null,
|
||||
providerOrder: ['claude-official', 'openai-official'],
|
||||
providerOrder: ['claude-official', 'openai-official', 'grok-official'],
|
||||
})
|
||||
})
|
||||
|
||||
@ -113,7 +121,7 @@ describe('ProviderService', () => {
|
||||
expect(result).toEqual({
|
||||
providers: [],
|
||||
activeId: null,
|
||||
providerOrder: ['claude-official', 'openai-official'],
|
||||
providerOrder: ['claude-official', 'openai-official', 'grok-official'],
|
||||
})
|
||||
expect(files.some((name) => name.startsWith('providers.json.invalid-'))).toBe(true)
|
||||
})
|
||||
@ -513,6 +521,104 @@ describe('ProviderService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Grok Official provider metadata', () => {
|
||||
test('normalizes the built-in Grok provider and appends it to legacy provider order', async () => {
|
||||
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, 'cc-haha', 'providers.json'),
|
||||
JSON.stringify({
|
||||
activeId: 'grok-official',
|
||||
providers: [],
|
||||
providerOrder: ['claude-official', 'openai-official'],
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
const svc = new ProviderService()
|
||||
const result = await svc.listProviders()
|
||||
|
||||
expect(result.activeId).toBe('grok-official')
|
||||
expect(result.providers).toEqual([])
|
||||
expect(result.providerOrder).toEqual([
|
||||
'claude-official',
|
||||
'openai-official',
|
||||
'grok-official',
|
||||
])
|
||||
})
|
||||
|
||||
test('returns and activates built-in Grok metadata while clearing OpenAI OAuth runtime env', async () => {
|
||||
const svc = new ProviderService()
|
||||
const provider = await svc.getProvider('grok-official')
|
||||
|
||||
expect(provider).toMatchObject({
|
||||
id: 'grok-official',
|
||||
presetId: 'grok-official',
|
||||
name: 'Grok Official',
|
||||
apiKey: '',
|
||||
apiFormat: 'openai_chat',
|
||||
runtimeKind: 'grok_oauth',
|
||||
models: {
|
||||
main: 'grok-4.5',
|
||||
haiku: 'grok-4.5',
|
||||
sonnet: 'grok-4.5',
|
||||
opus: 'grok-4.5',
|
||||
},
|
||||
})
|
||||
|
||||
await svc.activateProvider('openai-official')
|
||||
await svc.activateProvider('grok-official')
|
||||
|
||||
const config = await readProvidersConfig()
|
||||
const env = (await readSettings()).env as Record<string, string>
|
||||
expect(config.activeId).toBe('grok-official')
|
||||
expect(env.CC_HAHA_GROK_OAUTH_PROVIDER).toBe('1')
|
||||
expect(env.GROK_OAUTH_FILE).toBe(
|
||||
path.join(tmpDir, 'cc-haha', 'grok-oauth.json'),
|
||||
)
|
||||
expect(env.ANTHROPIC_MODEL).toBe('grok-4.5')
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('grok-4.5')
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('grok-4.5')
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('grok-4.5')
|
||||
expect(env.CC_HAHA_OPENAI_OAUTH_PROVIDER).toBeUndefined()
|
||||
expect(env.OPENAI_CODEX_OAUTH_FILE).toBeUndefined()
|
||||
})
|
||||
|
||||
test('auth status reports Grok Official from the isolated Grok token file', async () => {
|
||||
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, 'cc-haha', 'grok-oauth.json'),
|
||||
JSON.stringify({
|
||||
accessToken: 'grok-access',
|
||||
refreshToken: 'grok-refresh',
|
||||
expiresAt: Date.now() + 60 * 60_000,
|
||||
email: 'grok@example.com',
|
||||
clientId: 'grok-client',
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
const svc = new ProviderService()
|
||||
await svc.activateProvider('grok-official')
|
||||
|
||||
await expect(svc.checkAuthStatus()).resolves.toMatchObject({
|
||||
hasAuth: true,
|
||||
source: 'grok-oauth',
|
||||
activeProvider: 'Grok Official',
|
||||
})
|
||||
})
|
||||
|
||||
test('auth status reports Grok Official as unauthenticated without an isolated token file', async () => {
|
||||
const svc = new ProviderService()
|
||||
await svc.activateProvider('grok-official')
|
||||
|
||||
await expect(svc.checkAuthStatus()).resolves.toMatchObject({
|
||||
hasAuth: false,
|
||||
source: 'none',
|
||||
activeProvider: 'Grok Official',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('should throw 404 for non-existent id', async () => {
|
||||
const svc = new ProviderService()
|
||||
|
||||
@ -716,16 +822,40 @@ describe('ProviderService', () => {
|
||||
const a = await svc.addProvider(sampleInput({ name: 'A' }))
|
||||
const b = await svc.addProvider(sampleInput({ name: 'B' }))
|
||||
|
||||
const result = await svc.reorderProviders(['openai-official', b.id, 'claude-official', a.id])
|
||||
const result = await svc.reorderProviders([
|
||||
'openai-official',
|
||||
b.id,
|
||||
'claude-official',
|
||||
a.id,
|
||||
'grok-official',
|
||||
])
|
||||
|
||||
expect(result.providerOrder).toEqual(['openai-official', b.id, 'claude-official', a.id])
|
||||
expect(result.providerOrder).toEqual([
|
||||
'openai-official',
|
||||
b.id,
|
||||
'claude-official',
|
||||
a.id,
|
||||
'grok-official',
|
||||
])
|
||||
expect(result.providers.map((p) => p.id)).toEqual([b.id, a.id])
|
||||
|
||||
const listed = await svc.listProviders()
|
||||
expect(listed.providerOrder).toEqual(['openai-official', b.id, 'claude-official', a.id])
|
||||
expect(listed.providerOrder).toEqual([
|
||||
'openai-official',
|
||||
b.id,
|
||||
'claude-official',
|
||||
a.id,
|
||||
'grok-official',
|
||||
])
|
||||
|
||||
const config = await readProvidersConfig()
|
||||
expect(config.providerOrder).toEqual(['openai-official', b.id, 'claude-official', a.id])
|
||||
expect(config.providerOrder).toEqual([
|
||||
'openai-official',
|
||||
b.id,
|
||||
'claude-official',
|
||||
a.id,
|
||||
'grok-official',
|
||||
])
|
||||
})
|
||||
|
||||
test('should not change activeId when reordering', async () => {
|
||||
@ -1130,6 +1260,14 @@ describe('ProviderService', () => {
|
||||
expect(active).toBeNull()
|
||||
})
|
||||
|
||||
test('should return null for explicit Grok Official proxy lookup', async () => {
|
||||
const svc = new ProviderService()
|
||||
|
||||
const active = await svc.getProviderForProxy('grok-official')
|
||||
|
||||
expect(active).toBeNull()
|
||||
})
|
||||
|
||||
test('should return the active provider proxy config', async () => {
|
||||
const svc = new ProviderService()
|
||||
const provider = await svc.addProvider(sampleInput())
|
||||
@ -1150,6 +1288,15 @@ describe('ProviderService', () => {
|
||||
|
||||
expect(active).toBeNull()
|
||||
})
|
||||
|
||||
test('should return null when Grok Official is the active provider', async () => {
|
||||
const svc = new ProviderService()
|
||||
await svc.activateProvider('grok-official')
|
||||
|
||||
const active = await svc.getProviderForProxy()
|
||||
|
||||
expect(active).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleProxyRequest', () => {
|
||||
@ -1861,7 +2008,13 @@ describe('Providers API', () => {
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { providers: { name: string }[]; providerOrder: string[] }
|
||||
expect(body.providers.map((p) => p.name)).toEqual(['B', 'A'])
|
||||
expect(body.providerOrder).toEqual([b.id, a.id, 'claude-official', 'openai-official'])
|
||||
expect(body.providerOrder).toEqual([
|
||||
b.id,
|
||||
a.id,
|
||||
'claude-official',
|
||||
'openai-official',
|
||||
'grok-official',
|
||||
])
|
||||
})
|
||||
|
||||
test('PUT /api/providers/reorder should return 400 for a non-permutation', async () => {
|
||||
|
||||
@ -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)
|
||||
|
||||
51
src/server/api/haha-grok-oauth.ts
Normal file
51
src/server/api/haha-grok-oauth.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import {
|
||||
GROK_OAUTH_SUCCESS_HTML,
|
||||
hahaGrokOAuthService,
|
||||
} from '../services/hahaGrokOAuthService.js'
|
||||
import { errorResponse } from '../middleware/errorHandler.js'
|
||||
|
||||
export async function handleHahaGrokOAuthApi(
|
||||
req: Request,
|
||||
_url: URL,
|
||||
segments: string[],
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const action = segments[2]
|
||||
if (action === 'start' && req.method === 'POST') {
|
||||
const session = await hahaGrokOAuthService.startSession()
|
||||
return Response.json({
|
||||
authorizeUrl: session.authorizeUrl,
|
||||
state: session.state,
|
||||
})
|
||||
}
|
||||
|
||||
if (action === 'success' && req.method === 'GET') {
|
||||
return new Response(GROK_OAUTH_SUCCESS_HTML, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (action === undefined && req.method === 'GET') {
|
||||
const tokens = await hahaGrokOAuthService.ensureFreshTokens()
|
||||
if (!tokens) return Response.json({ loggedIn: false })
|
||||
return Response.json({
|
||||
loggedIn: true,
|
||||
expiresAt: tokens.expiresAt,
|
||||
email: tokens.email,
|
||||
})
|
||||
}
|
||||
|
||||
if (action === undefined && req.method === 'DELETE') {
|
||||
hahaGrokOAuthService.dispose()
|
||||
await hahaGrokOAuthService.deleteTokens()
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
|
||||
return Response.json({ error: 'Not Found' }, { status: 404 })
|
||||
} catch (error) {
|
||||
return errorResponse(error)
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,17 @@ import {
|
||||
OPENAI_OFFICIAL_PROVIDER_NAME,
|
||||
isOpenAIOfficialProviderId,
|
||||
} from '../services/openaiOfficialProvider.js'
|
||||
import { getGrokModelCatalog } from '../../services/grokAuth/modelCatalog.js'
|
||||
import {
|
||||
GROK_DEFAULT_MAIN_MODEL,
|
||||
type GrokModelCatalogEntry,
|
||||
} from '../../services/grokAuth/models.js'
|
||||
import {
|
||||
GROK_OFFICIAL_PROVIDER_ID,
|
||||
GROK_OFFICIAL_PROVIDER_NAME,
|
||||
isGrokOfficialProviderId,
|
||||
} from '../services/grokOfficialProvider.js'
|
||||
import { hahaGrokOAuthService } from '../services/hahaGrokOAuthService.js'
|
||||
|
||||
// ─── Fallback models (used when no provider is configured) ────────────────────
|
||||
|
||||
@ -136,6 +147,29 @@ async function getOpenAIModelList(): Promise<ApiModelInfo[]> {
|
||||
return buildOpenAIModelList(await getOpenAICodexModelCatalog())
|
||||
}
|
||||
|
||||
function buildGrokModelList(catalog: GrokModelCatalogEntry[]): ApiModelInfo[] {
|
||||
return catalog.map((model) => ({
|
||||
id: model.value,
|
||||
name: model.label,
|
||||
description: model.description,
|
||||
context: model.contextWindow ? String(model.contextWindow) : '',
|
||||
...(model.reasoningEffort && { defaultReasoningEffort: model.reasoningEffort }),
|
||||
...(model.supportsReasoningEffort === false
|
||||
? { supportedReasoningEfforts: [] }
|
||||
: model.reasoningEfforts
|
||||
? { supportedReasoningEfforts: model.reasoningEfforts }
|
||||
: {}),
|
||||
}))
|
||||
}
|
||||
|
||||
async function getGrokModelList(): Promise<ApiModelInfo[]> {
|
||||
const tokens = await hahaGrokOAuthService.ensureFreshTokens()
|
||||
return buildGrokModelList(await getGrokModelCatalog({
|
||||
...(tokens?.accessToken ? { accessToken: tokens.accessToken } : {}),
|
||||
accountKey: tokens?.email ?? (tokens ? 'authenticated-default' : 'logged-out'),
|
||||
}))
|
||||
}
|
||||
|
||||
function getEnvConfiguredAnthropicModels(): ApiModelInfo[] {
|
||||
return buildProviderModelList({
|
||||
main: process.env.ANTHROPIC_MODEL?.trim() || '',
|
||||
@ -220,6 +254,15 @@ async function handleModelsList(): Promise<Response> {
|
||||
},
|
||||
})
|
||||
}
|
||||
if (isGrokOfficialProviderId(activeId)) {
|
||||
return Response.json({
|
||||
models: await getGrokModelList(),
|
||||
provider: {
|
||||
id: GROK_OFFICIAL_PROVIDER_ID,
|
||||
name: GROK_OFFICIAL_PROVIDER_NAME,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const activeProvider = activeId ? providers.find((p) => p.id === activeId) : null
|
||||
if (activeProvider) {
|
||||
@ -237,8 +280,9 @@ async function handleCurrentModel(req: Request): Promise<Response> {
|
||||
// Build the full model list: prefer active provider's models, fall back to defaults
|
||||
const { providers, activeId } = await providerService.listProviders()
|
||||
const isOpenAIProviderActive = isOpenAIOfficialProviderId(activeId)
|
||||
const isGrokProviderActive = isGrokOfficialProviderId(activeId)
|
||||
const activeProvider = activeId ? providers.find((p) => p.id === activeId) : null
|
||||
const settings = activeProvider || isOpenAIProviderActive
|
||||
const settings = activeProvider || isOpenAIProviderActive || isGrokProviderActive
|
||||
? await providerService.getManagedSettings()
|
||||
: await settingsService.getUserSettings()
|
||||
const explicitModel = (settings.model as string) || ''
|
||||
@ -252,6 +296,9 @@ async function handleCurrentModel(req: Request): Promise<Response> {
|
||||
if (isOpenAIProviderActive) {
|
||||
currentModelId = explicitModel || env.ANTHROPIC_MODEL || OPENAI_DEFAULT_MAIN_MODEL
|
||||
currentModelName = currentModelId
|
||||
} else if (isGrokProviderActive) {
|
||||
currentModelId = explicitModel || env.ANTHROPIC_MODEL || GROK_DEFAULT_MAIN_MODEL
|
||||
currentModelName = currentModelId
|
||||
} else if (activeProvider) {
|
||||
// Provider is active — only use the provider-managed cc-haha settings.
|
||||
// This avoids leaking global ~/.claude/settings.json model choices into
|
||||
@ -275,9 +322,11 @@ async function handleCurrentModel(req: Request): Promise<Response> {
|
||||
// Build available models for name lookup
|
||||
const availableModels = isOpenAIProviderActive
|
||||
? await getOpenAIModelList()
|
||||
: activeProvider
|
||||
? buildProviderModelList(activeProvider.models)
|
||||
: await getStandaloneModelList()
|
||||
: isGrokProviderActive
|
||||
? await getGrokModelList()
|
||||
: activeProvider
|
||||
? buildProviderModelList(activeProvider.models)
|
||||
: await getStandaloneModelList()
|
||||
|
||||
const modelEntry = availableModels.find((m) => m.id === lookupId)
|
||||
|| availableModels.find((m) => m.id === currentModelId)
|
||||
|
||||
@ -19,6 +19,7 @@ import { handleMarketApi } from './api/market.js'
|
||||
import { handleComputerUseApi } from './api/computer-use.js'
|
||||
import { handleHahaOAuthApi } from './api/haha-oauth.js'
|
||||
import { handleHahaOpenAIOAuthApi } from './api/haha-openai-oauth.js'
|
||||
import { handleHahaGrokOAuthApi } from './api/haha-grok-oauth.js'
|
||||
import { handleMcpApi } from './api/mcp.js'
|
||||
import { handleDiagnosticsApi } from './api/diagnostics.js'
|
||||
import { handleDoctorApi } from './api/doctor.js'
|
||||
@ -84,6 +85,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
|
||||
case 'haha-openai-oauth':
|
||||
return handleHahaOpenAIOAuthApi(req, url, segments)
|
||||
|
||||
case 'haha-grok-oauth':
|
||||
return handleHahaGrokOAuthApi(req, url, segments)
|
||||
|
||||
case 'adapters':
|
||||
// Adapter protocols pull in platform SDKs that are unnecessary for the
|
||||
// core server path. Load them only when this API is actually used.
|
||||
|
||||
@ -15,6 +15,10 @@ import {
|
||||
OPENAI_OAUTH_PROVIDER_ENV_KEY,
|
||||
isOpenAIOfficialProviderId,
|
||||
} from './openaiOfficialProvider.js'
|
||||
import {
|
||||
GROK_OAUTH_FILE_ENV_KEY,
|
||||
GROK_OAUTH_PROVIDER_ENV_KEY,
|
||||
} from './grokOfficialProvider.js'
|
||||
import {
|
||||
OPENAI_CODEX_REASONING_EFFORT_ENV_KEY,
|
||||
isOpenAIReasoningEffort,
|
||||
@ -1167,6 +1171,8 @@ export class ConversationService {
|
||||
OPENAI_OAUTH_PROVIDER_ENV_KEY,
|
||||
OPENAI_CODEX_OAUTH_FILE_ENV_KEY,
|
||||
OPENAI_CODEX_REASONING_EFFORT_ENV_KEY,
|
||||
GROK_OAUTH_PROVIDER_ENV_KEY,
|
||||
GROK_OAUTH_FILE_ENV_KEY,
|
||||
] as const
|
||||
|
||||
const cleanEnv = await getProcessEnvWithTerminalShellEnvironment()
|
||||
@ -1414,6 +1420,8 @@ export class ConversationService {
|
||||
'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS',
|
||||
OPENAI_OAUTH_PROVIDER_ENV_KEY,
|
||||
OPENAI_CODEX_OAUTH_FILE_ENV_KEY,
|
||||
GROK_OAUTH_PROVIDER_ENV_KEY,
|
||||
GROK_OAUTH_FILE_ENV_KEY,
|
||||
].some((key) => typeof env[key] === 'string' && env[key]!.trim().length > 0)
|
||||
} catch {
|
||||
return false
|
||||
@ -1447,6 +1455,9 @@ export class ConversationService {
|
||||
if (env[OPENAI_OAUTH_PROVIDER_ENV_KEY] === '1') {
|
||||
return false
|
||||
}
|
||||
if (env[GROK_OAUTH_PROVIDER_ENV_KEY] === '1') {
|
||||
return false
|
||||
}
|
||||
const hasProviderEnv = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
|
||||
@ -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) {
|
||||
|
||||
58
src/server/services/grokOfficialProvider.ts
Normal file
58
src/server/services/grokOfficialProvider.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import {
|
||||
GROK_DEFAULT_HAIKU_MODEL,
|
||||
GROK_DEFAULT_MAIN_MODEL,
|
||||
GROK_DEFAULT_SONNET_MODEL,
|
||||
GROK_MODEL_CATALOG,
|
||||
getGrokContextWindowForModel,
|
||||
} from '../../services/grokAuth/models.js'
|
||||
import { GROK_OAUTH_FILE_ENV_KEY } from '../../services/grokAuth/storage.js'
|
||||
import { MODEL_CONTEXT_WINDOWS_ENV_KEY } from '../../utils/model/modelContextWindows.js'
|
||||
import {
|
||||
GROK_OFFICIAL_PROVIDER_ID,
|
||||
type SavedProvider,
|
||||
} from '../types/provider.js'
|
||||
import { getHahaGrokOAuthFilePath } from './hahaGrokOAuthService.js'
|
||||
|
||||
export { GROK_OFFICIAL_PROVIDER_ID, GROK_OAUTH_FILE_ENV_KEY }
|
||||
export const GROK_OFFICIAL_PROVIDER_NAME = 'Grok Official'
|
||||
export const GROK_OAUTH_PROVIDER_ENV_KEY = 'CC_HAHA_GROK_OAUTH_PROVIDER'
|
||||
|
||||
export function isGrokOfficialProviderId(id: string | null | undefined): boolean {
|
||||
return id === GROK_OFFICIAL_PROVIDER_ID
|
||||
}
|
||||
|
||||
const modelContextWindows = Object.fromEntries(
|
||||
GROK_MODEL_CATALOG
|
||||
.map(({ value }) => [value, getGrokContextWindowForModel(value)] as const)
|
||||
.filter((entry): entry is readonly [string, number] => entry[1] !== null),
|
||||
)
|
||||
|
||||
export const GROK_OFFICIAL_PROVIDER: SavedProvider = {
|
||||
id: GROK_OFFICIAL_PROVIDER_ID,
|
||||
presetId: GROK_OFFICIAL_PROVIDER_ID,
|
||||
name: GROK_OFFICIAL_PROVIDER_NAME,
|
||||
apiKey: '',
|
||||
authStrategy: 'dual_dummy',
|
||||
baseUrl: 'https://cli-chat-proxy.grok.com/v1',
|
||||
apiFormat: 'openai_chat',
|
||||
runtimeKind: 'grok_oauth',
|
||||
models: {
|
||||
main: GROK_DEFAULT_MAIN_MODEL,
|
||||
haiku: GROK_DEFAULT_HAIKU_MODEL,
|
||||
sonnet: GROK_DEFAULT_SONNET_MODEL,
|
||||
opus: GROK_DEFAULT_MAIN_MODEL,
|
||||
},
|
||||
modelContextWindows,
|
||||
}
|
||||
|
||||
export function buildGrokOfficialRuntimeEnv(): Record<string, string> {
|
||||
return {
|
||||
[GROK_OAUTH_PROVIDER_ENV_KEY]: '1',
|
||||
[GROK_OAUTH_FILE_ENV_KEY]: getHahaGrokOAuthFilePath(),
|
||||
[MODEL_CONTEXT_WINDOWS_ENV_KEY]: JSON.stringify(modelContextWindows),
|
||||
ANTHROPIC_MODEL: GROK_DEFAULT_MAIN_MODEL,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: GROK_DEFAULT_HAIKU_MODEL,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: GROK_DEFAULT_SONNET_MODEL,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: GROK_DEFAULT_MAIN_MODEL,
|
||||
}
|
||||
}
|
||||
248
src/server/services/hahaGrokOAuthService.ts
Normal file
248
src/server/services/hahaGrokOAuthService.ts
Normal file
@ -0,0 +1,248 @@
|
||||
import * as fs from 'fs/promises'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import { AuthCodeListener } from '../../services/oauth/auth-code-listener.js'
|
||||
import {
|
||||
buildGrokAuthorizeUrl,
|
||||
exchangeGrokCodeForTokens,
|
||||
generateGrokCodeVerifier,
|
||||
generateGrokNonce,
|
||||
generateGrokState,
|
||||
isGrokTokenExpired,
|
||||
normalizeGrokTokens,
|
||||
refreshGrokTokens,
|
||||
withRefreshedGrokAccessToken,
|
||||
type GrokTokenFetchOptions,
|
||||
} from '../../services/grokAuth/client.js'
|
||||
import type { GrokOAuthTokenResponse } from '../../services/grokAuth/types.js'
|
||||
import { logTokenRefreshFailure } from './oauthRefreshLog.js'
|
||||
import {
|
||||
getManualNetworkProxyUrl,
|
||||
loadNetworkSettings,
|
||||
} from './networkSettings.js'
|
||||
|
||||
export type StoredGrokOAuthTokens = {
|
||||
accessToken: string
|
||||
refreshToken: string | null
|
||||
expiresAt: number | null
|
||||
idToken?: string | null
|
||||
email: string | null
|
||||
clientId?: string | null
|
||||
}
|
||||
|
||||
export type GrokOAuthSession = {
|
||||
state: string
|
||||
codeVerifier: string
|
||||
authorizeUrl: string
|
||||
redirectUri: string
|
||||
createdAt: number
|
||||
authCodeListener?: AuthCodeListener
|
||||
expiresTimer?: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
type GrokRefreshFn = (
|
||||
refreshToken: string,
|
||||
options?: GrokTokenFetchOptions,
|
||||
) => Promise<GrokOAuthTokenResponse>
|
||||
|
||||
const SESSION_TTL_MS = 10 * 60 * 1000
|
||||
const CALLBACK_PATH = '/callback'
|
||||
|
||||
export const GROK_OAUTH_SUCCESS_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>Grok Login Success</title>
|
||||
<style>body{font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#fafafa;color:#333}.card{text-align:center;padding:40px;background:white;border-radius:12px;box-shadow:0 4px 16px rgba(0,0,0,.06)}h1{color:#16a34a;margin:0 0 12px}p{color:#666}</style>
|
||||
</head><body><div class="card"><h1>✓ Grok Login Successful</h1><p>Authorization is complete. You can close this window and return to Claude Code Haha.</p></div><script>setTimeout(() => window.close(), 3000)</script></body></html>`
|
||||
|
||||
function renderErrorHtml(message: string): string {
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><title>Grok Login Failed</title>
|
||||
<style>body{font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#fafafa;color:#333}.card{text-align:center;padding:40px;background:white;border-radius:12px;box-shadow:0 4px 16px rgba(0,0,0,.06)}h1{color:#dc2626;margin:0 0 12px}pre{color:#666;white-space:pre-wrap;word-break:break-word;text-align:left;background:#f5f5f5;padding:12px;border-radius:6px}</style>
|
||||
</head><body><div class="card"><h1>Grok Login Failed</h1><pre>${escapeHtml(message)}</pre></div></body></html>`
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.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<string, GrokOAuthSession>()
|
||||
private refreshFn: GrokRefreshFn = refreshGrokTokens
|
||||
|
||||
setRefreshFn(fn: GrokRefreshFn): void {
|
||||
this.refreshFn = fn
|
||||
}
|
||||
|
||||
getOAuthFilePath(): string {
|
||||
return getHahaGrokOAuthFilePath()
|
||||
}
|
||||
|
||||
async loadTokens(): Promise<StoredGrokOAuthTokens | null> {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(this.getOAuthFilePath(), 'utf-8')) as StoredGrokOAuthTokens
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async saveTokens(tokens: StoredGrokOAuthTokens): Promise<void> {
|
||||
const filePath = this.getOAuthFilePath()
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||
const temporaryPath = `${filePath}.tmp.${process.pid}.${Date.now()}`
|
||||
let renamed = false
|
||||
try {
|
||||
await fs.writeFile(temporaryPath, `${JSON.stringify(tokens, null, 2)}\n`, { mode: 0o600 })
|
||||
await fs.rename(temporaryPath, filePath)
|
||||
renamed = true
|
||||
} finally {
|
||||
if (!renamed) await fs.rm(temporaryPath, { force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTokens(): Promise<void> {
|
||||
await fs.rm(this.getOAuthFilePath(), { force: true })
|
||||
}
|
||||
|
||||
async startSession(): Promise<GrokOAuthSession> {
|
||||
this.dispose()
|
||||
const codeVerifier = generateGrokCodeVerifier()
|
||||
const state = generateGrokState()
|
||||
const nonce = generateGrokNonce()
|
||||
const authCodeListener = new AuthCodeListener(CALLBACK_PATH)
|
||||
const port = await authCodeListener.start(undefined, '127.0.0.1')
|
||||
const redirectUri = `http://127.0.0.1:${port}${CALLBACK_PATH}`
|
||||
const authorizeUrl = buildGrokAuthorizeUrl({ redirectUri, codeVerifier, state, nonce })
|
||||
const session: GrokOAuthSession = {
|
||||
state,
|
||||
codeVerifier,
|
||||
authorizeUrl,
|
||||
redirectUri,
|
||||
createdAt: Date.now(),
|
||||
authCodeListener,
|
||||
}
|
||||
session.expiresTimer = setTimeout(() => {
|
||||
if (this.sessions.get(state) === session) {
|
||||
this.closeSession(session)
|
||||
this.sessions.delete(state)
|
||||
}
|
||||
}, SESSION_TTL_MS)
|
||||
session.expiresTimer.unref?.()
|
||||
this.sessions.set(state, session)
|
||||
this.waitForDesktopCallback(session)
|
||||
return session
|
||||
}
|
||||
|
||||
private waitForDesktopCallback(session: GrokOAuthSession): void {
|
||||
const listener = session.authCodeListener
|
||||
if (!listener) return
|
||||
void listener.waitForAuthorization(session.state, async () => {})
|
||||
.then(async (code) => {
|
||||
try {
|
||||
await this.completeSession(code, session.state)
|
||||
listener.handleSuccessRedirect([], (response) => {
|
||||
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
||||
response.end(GROK_OAUTH_SUCCESS_HTML)
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
listener.handleSuccessRedirect([], (response) => {
|
||||
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
||||
response.end(renderErrorHtml(message))
|
||||
})
|
||||
} finally {
|
||||
this.closeSession(session)
|
||||
this.sessions.delete(session.state)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.closeSession(session)
|
||||
this.sessions.delete(session.state)
|
||||
})
|
||||
}
|
||||
|
||||
async completeSession(code: string, state: string): Promise<StoredGrokOAuthTokens> {
|
||||
const session = this.sessions.get(state)
|
||||
if (!session || Date.now() - session.createdAt > SESSION_TTL_MS) {
|
||||
throw new Error('Grok OAuth session not found or expired')
|
||||
}
|
||||
this.sessions.delete(state)
|
||||
const response = await exchangeGrokCodeForTokens({
|
||||
code,
|
||||
redirectUri: session.redirectUri,
|
||||
codeVerifier: session.codeVerifier,
|
||||
...(await this.getTokenFetchOptions()),
|
||||
})
|
||||
const normalized = normalizeGrokTokens(response)
|
||||
const tokens: StoredGrokOAuthTokens = {
|
||||
accessToken: normalized.accessToken,
|
||||
refreshToken: normalized.refreshToken,
|
||||
expiresAt: normalized.expiresAt,
|
||||
idToken: normalized.idToken ?? null,
|
||||
email: normalized.email ?? null,
|
||||
clientId: normalized.clientId ?? null,
|
||||
}
|
||||
await this.saveTokens(tokens)
|
||||
return tokens
|
||||
}
|
||||
|
||||
async ensureFreshTokens(): Promise<StoredGrokOAuthTokens | null> {
|
||||
const tokens = await this.loadTokens()
|
||||
if (!tokens) return null
|
||||
if (tokens.expiresAt === null || !isGrokTokenExpired(tokens.expiresAt)) return tokens
|
||||
if (!tokens.refreshToken) return null
|
||||
try {
|
||||
const response = await this.refreshFn(tokens.refreshToken, await this.getTokenFetchOptions())
|
||||
const normalized = withRefreshedGrokAccessToken({
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
expiresAt: tokens.expiresAt,
|
||||
...(tokens.idToken ? { idToken: tokens.idToken } : {}),
|
||||
...(tokens.email ? { email: tokens.email } : {}),
|
||||
...(tokens.clientId ? { clientId: tokens.clientId } : {}),
|
||||
}, response)
|
||||
const updated: StoredGrokOAuthTokens = {
|
||||
accessToken: normalized.accessToken,
|
||||
refreshToken: normalized.refreshToken,
|
||||
expiresAt: normalized.expiresAt,
|
||||
idToken: normalized.idToken ?? null,
|
||||
email: normalized.email ?? null,
|
||||
clientId: normalized.clientId ?? null,
|
||||
}
|
||||
await this.saveTokens(updated)
|
||||
return updated
|
||||
} catch (error) {
|
||||
logTokenRefreshFailure('[HahaGrokOAuthService]', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const session of this.sessions.values()) this.closeSession(session)
|
||||
this.sessions.clear()
|
||||
}
|
||||
|
||||
private closeSession(session: GrokOAuthSession): void {
|
||||
if (session.expiresTimer) clearTimeout(session.expiresTimer)
|
||||
session.expiresTimer = undefined
|
||||
session.authCodeListener?.close()
|
||||
session.authCodeListener = undefined
|
||||
}
|
||||
|
||||
private async getTokenFetchOptions(): Promise<GrokTokenFetchOptions> {
|
||||
const settings = await loadNetworkSettings()
|
||||
return {
|
||||
proxyUrl: getManualNetworkProxyUrl(settings),
|
||||
timeoutMs: settings.aiRequestTimeoutMs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const hahaGrokOAuthService = new HahaGrokOAuthService()
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -9,11 +9,17 @@ import { z } from 'zod'
|
||||
|
||||
export const CLAUDE_OFFICIAL_PROVIDER_ID = 'claude-official'
|
||||
export const OPENAI_OFFICIAL_PROVIDER_ID = 'openai-official'
|
||||
export const GROK_OFFICIAL_PROVIDER_ID = 'grok-official'
|
||||
export const BUILT_IN_PROVIDER_IDS = [
|
||||
CLAUDE_OFFICIAL_PROVIDER_ID,
|
||||
OPENAI_OFFICIAL_PROVIDER_ID,
|
||||
GROK_OFFICIAL_PROVIDER_ID,
|
||||
] as const
|
||||
|
||||
export function isBuiltInProviderId(id: string | null | undefined): boolean {
|
||||
return !!id && (BUILT_IN_PROVIDER_IDS as readonly string[]).includes(id)
|
||||
}
|
||||
|
||||
export const ApiFormatSchema = z.enum([
|
||||
'anthropic', // Native Anthropic Messages API (passthrough, no proxy)
|
||||
'openai_chat', // OpenAI Chat Completions /v1/chat/completions
|
||||
@ -33,6 +39,7 @@ export type ProviderAuthStrategy = z.infer<typeof ProviderAuthStrategySchema>
|
||||
export const ProviderRuntimeKindSchema = z.enum([
|
||||
'anthropic_compatible',
|
||||
'openai_oauth',
|
||||
'grok_oauth',
|
||||
])
|
||||
export type ProviderRuntimeKind = z.infer<typeof ProviderRuntimeKindSchema>
|
||||
|
||||
|
||||
@ -24,12 +24,16 @@ import { sessionService } from '../services/sessionService.js'
|
||||
import { SettingsService } from '../services/settingsService.js'
|
||||
import { ProviderService } from '../services/providerService.js'
|
||||
import { isOpenAIOfficialProviderId } from '../services/openaiOfficialProvider.js'
|
||||
import { isGrokOfficialProviderId } from '../services/grokOfficialProvider.js'
|
||||
import { getOpenAICodexModelCatalog } from '../../services/openaiAuth/modelCatalog.js'
|
||||
import {
|
||||
OPENAI_DEFAULT_MAIN_MODEL,
|
||||
getOpenAIModelCatalogEntry,
|
||||
isOpenAIReasoningEffort,
|
||||
} from '../../services/openaiAuth/models.js'
|
||||
import { GROK_DEFAULT_MAIN_MODEL } from '../../services/grokAuth/models.js'
|
||||
import { getGrokModelCatalog } from '../../services/grokAuth/modelCatalog.js'
|
||||
import { hahaGrokOAuthService } from '../services/hahaGrokOAuthService.js'
|
||||
import { diagnosticsService } from '../services/diagnosticsService.js'
|
||||
import {
|
||||
buildConversationTitleInput,
|
||||
@ -822,7 +826,7 @@ async function handleSetRuntimeConfig(
|
||||
message: Extract<ClientMessage, { type: 'set_runtime_config' }>
|
||||
) {
|
||||
const { sessionId } = ws.data
|
||||
const modelId = typeof message.modelId === 'string' ? message.modelId.trim() : ''
|
||||
let modelId = typeof message.modelId === 'string' ? message.modelId.trim() : ''
|
||||
if (!modelId) {
|
||||
sendMessage(ws, {
|
||||
type: 'error',
|
||||
@ -831,6 +835,9 @@ async function handleSetRuntimeConfig(
|
||||
})
|
||||
return
|
||||
}
|
||||
if (isGrokOfficialProviderId(message.providerId)) {
|
||||
modelId = (await getGrokReasoningEfforts(modelId)).modelId
|
||||
}
|
||||
const effortLevel =
|
||||
typeof message.effortLevel === 'string' ? message.effortLevel.trim() : undefined
|
||||
if (
|
||||
@ -2715,11 +2722,35 @@ async function getDefaultOpenAIReasoningEffort(modelId: string): Promise<string>
|
||||
return getOpenAIModelCatalogEntry(modelId, catalog)?.defaultReasoningEffort ?? 'medium'
|
||||
}
|
||||
|
||||
async function getGrokReasoningEfforts(modelId: string): Promise<{
|
||||
modelId: string
|
||||
defaultEffort?: string
|
||||
supportedEfforts: string[]
|
||||
}> {
|
||||
const tokens = await hahaGrokOAuthService.ensureFreshTokens()
|
||||
const catalog = await getGrokModelCatalog({
|
||||
...(tokens?.accessToken ? { accessToken: tokens.accessToken } : {}),
|
||||
accountKey: tokens?.email ?? (tokens ? 'authenticated-default' : 'logged-out'),
|
||||
})
|
||||
const model = catalog.find((entry) => entry.value === modelId)
|
||||
?? catalog.find((entry) => entry.value === GROK_DEFAULT_MAIN_MODEL)
|
||||
?? catalog[0]
|
||||
return {
|
||||
modelId: model?.value ?? GROK_DEFAULT_MAIN_MODEL,
|
||||
...(model?.reasoningEffort ? { defaultEffort: model.reasoningEffort } : {}),
|
||||
supportedEfforts: model?.reasoningEfforts ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
async function isRuntimeEffortSupported(
|
||||
providerId: string | null | undefined,
|
||||
modelId: string,
|
||||
effort: string,
|
||||
): Promise<boolean> {
|
||||
if (isGrokOfficialProviderId(providerId)) {
|
||||
const { supportedEfforts } = await getGrokReasoningEfforts(modelId)
|
||||
return supportedEfforts.includes(effort)
|
||||
}
|
||||
if (!isOpenAIOfficialProviderId(providerId)) {
|
||||
return VALID_CLAUDE_EFFORT_LEVELS.has(effort)
|
||||
}
|
||||
@ -2738,6 +2769,7 @@ function isKnownRuntimeProviderId(
|
||||
): boolean {
|
||||
return (
|
||||
isOpenAIOfficialProviderId(providerId) ||
|
||||
isGrokOfficialProviderId(providerId) ||
|
||||
providers.some((provider) => provider.id === providerId)
|
||||
)
|
||||
}
|
||||
@ -2782,11 +2814,16 @@ async function getRuntimeSettings(sessionId?: string): Promise<RuntimeSettings>
|
||||
userSettings,
|
||||
runtimeOverride.providerId,
|
||||
)
|
||||
const effort = runtimeOverride.effort ?? (
|
||||
isOpenAIOfficialProviderId(runtimeOverride.providerId)
|
||||
? await getDefaultOpenAIReasoningEffort(runtimeOverride.modelId)
|
||||
: undefined
|
||||
)
|
||||
let effort = runtimeOverride.effort
|
||||
if (isOpenAIOfficialProviderId(runtimeOverride.providerId)) {
|
||||
effort = effort ?? await getDefaultOpenAIReasoningEffort(runtimeOverride.modelId)
|
||||
} else if (isGrokOfficialProviderId(runtimeOverride.providerId)) {
|
||||
const grokEffort = await getGrokReasoningEfforts(runtimeOverride.modelId)
|
||||
runtimeOverride.modelId = grokEffort.modelId
|
||||
effort = effort && grokEffort.supportedEfforts.includes(effort)
|
||||
? effort
|
||||
: grokEffort.defaultEffort
|
||||
}
|
||||
|
||||
return {
|
||||
permissionMode: sessionPermissionMode ?? await settingsService.getPermissionMode().catch(() => undefined),
|
||||
@ -2850,6 +2887,9 @@ async function getDefaultRuntimeSettings(): Promise<RuntimeSettings> {
|
||||
if (isOpenAIOfficialProviderId(resolvedActiveId)) {
|
||||
model = model || OPENAI_DEFAULT_MAIN_MODEL
|
||||
effort = await getDefaultOpenAIReasoningEffort(model)
|
||||
} else if (isGrokOfficialProviderId(resolvedActiveId)) {
|
||||
model = model || GROK_DEFAULT_MAIN_MODEL
|
||||
effort = (await getGrokReasoningEfforts(model)).defaultEffort
|
||||
}
|
||||
} else {
|
||||
// No provider — pass model normally
|
||||
|
||||
@ -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
|
||||
|
||||
@ -29,6 +29,11 @@ import {
|
||||
shouldUseOpenAICodexAuth,
|
||||
} from '../openaiAuth/fetch.js'
|
||||
import { isOpenAIResponsesModel } from '../openaiAuth/models.js'
|
||||
import {
|
||||
buildGrokFetch,
|
||||
GROK_OAUTH_DUMMY_KEY,
|
||||
shouldUseGrokAuth,
|
||||
} from '../grokAuth/fetch.js'
|
||||
import { isDebugToStdErr, logForDebugging } from '../../utils/debug.js'
|
||||
import {
|
||||
getAWSRegion,
|
||||
@ -183,9 +188,11 @@ export async function getAnthropicClient({
|
||||
logForDebugging('[API:auth] OAuth token check complete')
|
||||
|
||||
const isOpenAIModel = model ? isOpenAIResponsesModel(model) : false
|
||||
const isClaudeSubscriber = isClaudeAISubscriber()
|
||||
const forceOpenAICodex = isEnvTruthy(process.env.CC_HAHA_OPENAI_OAUTH_PROVIDER)
|
||||
const forceGrok = isEnvTruthy(process.env.CC_HAHA_GROK_OAUTH_PROVIDER)
|
||||
const isClaudeSubscriber = forceGrok ? false : isClaudeAISubscriber()
|
||||
const hasOpenAIAuth = shouldUseOpenAICodexAuth()
|
||||
const usingGrok = forceGrok && shouldUseGrokAuth()
|
||||
const hasFallbackApiKey = hasOpenAIAuth &&
|
||||
!process.env.ANTHROPIC_AUTH_TOKEN &&
|
||||
!apiKey &&
|
||||
@ -200,13 +207,15 @@ export async function getAnthropicClient({
|
||||
hasFallbackApiKey,
|
||||
})
|
||||
|
||||
if (!isClaudeSubscriber && !usingOpenAICodex) {
|
||||
if (!isClaudeSubscriber && !usingOpenAICodex && !usingGrok) {
|
||||
await configureApiKeyHeaders(defaultHeaders, getIsNonInteractiveSession())
|
||||
}
|
||||
|
||||
const resolvedFetch = usingOpenAICodex
|
||||
? buildOpenAICodexFetch(fetchOverride, source)
|
||||
: buildFetch(fetchOverride, source)
|
||||
const resolvedFetch = usingGrok
|
||||
? buildGrokFetch(fetchOverride, source)
|
||||
: usingOpenAICodex
|
||||
? buildOpenAICodexFetch(fetchOverride, source)
|
||||
: buildFetch(fetchOverride, source)
|
||||
const stagingOAuthBaseUrl = process.env.USER_TYPE === 'ant' &&
|
||||
isEnvTruthy(process.env.USE_STAGING_OAUTH)
|
||||
? getOauthConfig().BASE_API_URL
|
||||
@ -377,10 +386,12 @@ export async function getAnthropicClient({
|
||||
const clientConfig: ConstructorParameters<typeof Anthropic>[0] = {
|
||||
apiKey: usingOpenAICodex
|
||||
? OPENAI_OAUTH_DUMMY_KEY
|
||||
: usingGrok
|
||||
? GROK_OAUTH_DUMMY_KEY
|
||||
: isClaudeSubscriber
|
||||
? null
|
||||
: resolveAnthropicClientApiKey({ explicitApiKey: apiKey }),
|
||||
authToken: isClaudeSubscriber && !usingOpenAICodex
|
||||
authToken: isClaudeSubscriber && !usingOpenAICodex && !usingGrok
|
||||
? getClaudeAIOAuthTokens()?.accessToken
|
||||
: undefined,
|
||||
// Set baseURL from OAuth config when using staging OAuth
|
||||
|
||||
64
src/services/grokAuth/client.test.ts
Normal file
64
src/services/grokAuth/client.test.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
buildGrokAuthorizeUrl,
|
||||
exchangeGrokCodeForTokens,
|
||||
generateGrokCodeVerifier,
|
||||
generateGrokNonce,
|
||||
generateGrokState,
|
||||
GROK_OAUTH_CLIENT_ID,
|
||||
GROK_OAUTH_TOKEN_ENDPOINT,
|
||||
refreshGrokTokens,
|
||||
} from './client.js'
|
||||
|
||||
describe('Grok OAuth client', () => {
|
||||
test('builds the xAI PKCE authorization request', () => {
|
||||
const verifier = generateGrokCodeVerifier()
|
||||
expect(verifier).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
||||
expect(generateGrokState()).toMatch(/^[a-f0-9]{64}$/)
|
||||
expect(generateGrokNonce()).toMatch(/^[a-f0-9]{32}$/)
|
||||
|
||||
const url = new URL(buildGrokAuthorizeUrl({
|
||||
redirectUri: 'http://127.0.0.1:56121/callback',
|
||||
codeVerifier: verifier,
|
||||
state: 'state',
|
||||
nonce: 'nonce',
|
||||
}))
|
||||
expect(url.origin + url.pathname).toBe('https://auth.x.ai/oauth2/authorize')
|
||||
expect(url.searchParams.get('client_id')).toBe(GROK_OAUTH_CLIENT_ID)
|
||||
expect(url.searchParams.get('scope')).toBe(
|
||||
'openid profile email offline_access grok-cli:access api:access conversations:read conversations:write',
|
||||
)
|
||||
expect(url.searchParams.get('code_challenge_method')).toBe('S256')
|
||||
expect(url.searchParams.has('plan')).toBe(false)
|
||||
expect(url.searchParams.has('referrer')).toBe(false)
|
||||
})
|
||||
|
||||
test('exchanges and refreshes tokens without a client secret', async () => {
|
||||
const requests: Array<{ url: string; body: URLSearchParams }> = []
|
||||
const fetchOverride: typeof fetch = async (input, init) => {
|
||||
requests.push({
|
||||
url: String(input),
|
||||
body: new URLSearchParams(String(init?.body)),
|
||||
})
|
||||
return Response.json({ access_token: 'access', refresh_token: 'refresh' })
|
||||
}
|
||||
|
||||
await exchangeGrokCodeForTokens({
|
||||
code: 'code',
|
||||
codeVerifier: 'verifier',
|
||||
redirectUri: 'http://127.0.0.1:56121/callback',
|
||||
fetchOverride,
|
||||
})
|
||||
await refreshGrokTokens('refresh', { fetchOverride })
|
||||
|
||||
expect(requests.map((request) => request.url)).toEqual([
|
||||
GROK_OAUTH_TOKEN_ENDPOINT,
|
||||
GROK_OAUTH_TOKEN_ENDPOINT,
|
||||
])
|
||||
expect(requests[0]!.body.get('grant_type')).toBe('authorization_code')
|
||||
expect(requests[0]!.body.get('code_verifier')).toBe('verifier')
|
||||
expect(requests[0]!.body.has('client_secret')).toBe(false)
|
||||
expect(requests[1]!.body.get('grant_type')).toBe('refresh_token')
|
||||
expect(requests[1]!.body.get('refresh_token')).toBe('refresh')
|
||||
})
|
||||
})
|
||||
205
src/services/grokAuth/client.ts
Normal file
205
src/services/grokAuth/client.ts
Normal file
@ -0,0 +1,205 @@
|
||||
import { randomBytes } from 'crypto'
|
||||
import { generateCodeChallenge } from '../oauth/crypto.js'
|
||||
import type {
|
||||
GrokJwtClaims,
|
||||
GrokOAuthTokenResponse,
|
||||
GrokOAuthTokens,
|
||||
} from './types.js'
|
||||
|
||||
export const GROK_OAUTH_ISSUER = 'https://auth.x.ai'
|
||||
export const GROK_OAUTH_AUTHORIZE_ENDPOINT =
|
||||
`${GROK_OAUTH_ISSUER}/oauth2/authorize`
|
||||
export const GROK_OAUTH_TOKEN_ENDPOINT = `${GROK_OAUTH_ISSUER}/oauth2/token`
|
||||
export const GROK_OAUTH_CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828'
|
||||
export const GROK_OAUTH_SCOPE =
|
||||
'openid profile email offline_access grok-cli:access api:access conversations:read conversations:write'
|
||||
export const GROK_OAUTH_REDIRECT_PATH = '/callback'
|
||||
|
||||
const DEFAULT_TOKEN_LIFETIME_SECONDS = 6 * 60 * 60
|
||||
const TOKEN_EXPIRY_SKEW_MS = 5 * 60_000
|
||||
const TOKEN_ERROR_BODY_LIMIT = 500
|
||||
|
||||
export type GrokTokenFetchOptions = {
|
||||
fetchOverride?: typeof fetch
|
||||
proxyUrl?: string | null
|
||||
timeoutMs?: number
|
||||
clientId?: string
|
||||
}
|
||||
|
||||
export function generateGrokState(): string {
|
||||
return randomBytes(32).toString('hex')
|
||||
}
|
||||
|
||||
export function generateGrokNonce(): string {
|
||||
return randomBytes(16).toString('hex')
|
||||
}
|
||||
|
||||
export function generateGrokCodeVerifier(): string {
|
||||
return randomBytes(32).toString('base64url')
|
||||
}
|
||||
|
||||
export function buildGrokAuthorizeUrl(input: {
|
||||
redirectUri: string
|
||||
codeVerifier: string
|
||||
state: string
|
||||
nonce: string
|
||||
clientId?: string
|
||||
}): string {
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: input.clientId?.trim() || GROK_OAUTH_CLIENT_ID,
|
||||
redirect_uri: input.redirectUri,
|
||||
scope: GROK_OAUTH_SCOPE,
|
||||
state: input.state,
|
||||
nonce: input.nonce,
|
||||
code_challenge: generateCodeChallenge(input.codeVerifier),
|
||||
code_challenge_method: 'S256',
|
||||
})
|
||||
return `${GROK_OAUTH_AUTHORIZE_ENDPOINT}?${params.toString()}`
|
||||
}
|
||||
|
||||
export async function exchangeGrokCodeForTokens(input: {
|
||||
code: string
|
||||
redirectUri: string
|
||||
codeVerifier: string
|
||||
} & GrokTokenFetchOptions): Promise<GrokOAuthTokenResponse> {
|
||||
return requestGrokTokens(
|
||||
new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: input.clientId?.trim() || GROK_OAUTH_CLIENT_ID,
|
||||
code: input.code,
|
||||
redirect_uri: input.redirectUri,
|
||||
code_verifier: input.codeVerifier,
|
||||
}),
|
||||
'exchange',
|
||||
input,
|
||||
)
|
||||
}
|
||||
|
||||
export async function refreshGrokTokens(
|
||||
refreshToken: string,
|
||||
options: GrokTokenFetchOptions = {},
|
||||
): Promise<GrokOAuthTokenResponse> {
|
||||
return requestGrokTokens(
|
||||
new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: options.clientId?.trim() || GROK_OAUTH_CLIENT_ID,
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
'refresh',
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
async function requestGrokTokens(
|
||||
body: URLSearchParams,
|
||||
operation: 'exchange' | 'refresh',
|
||||
options: GrokTokenFetchOptions,
|
||||
): Promise<GrokOAuthTokenResponse> {
|
||||
const fetchOverride = options.fetchOverride ?? globalThis.fetch
|
||||
const proxyOptions = options.proxyUrl
|
||||
? getGrokProxyFetchOptions(options.proxyUrl)
|
||||
: {}
|
||||
const response = await fetchOverride(GROK_OAUTH_TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'User-Agent': 'cc-haha-grok-oauth/1.0',
|
||||
},
|
||||
body: body.toString(),
|
||||
...(options.timeoutMs
|
||||
? { signal: AbortSignal.timeout(options.timeoutMs) }
|
||||
: {}),
|
||||
...(await proxyOptions),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const raw = await response.text().catch(() => '')
|
||||
const sanitized = sanitizeTokenError(raw)
|
||||
throw new Error(
|
||||
`Grok token ${operation} failed: ${response.status}${sanitized ? `: ${sanitized}` : ''}`,
|
||||
)
|
||||
}
|
||||
return (await response.json()) as GrokOAuthTokenResponse
|
||||
}
|
||||
|
||||
async function getGrokProxyFetchOptions(
|
||||
proxyUrl: string,
|
||||
): Promise<RequestInit> {
|
||||
const { getProxyFetchOptions } = await import('../../utils/proxy.js')
|
||||
return getProxyFetchOptions({ proxyUrl })
|
||||
}
|
||||
|
||||
function sanitizeTokenError(body: string): string {
|
||||
return body
|
||||
.replace(
|
||||
/"((?:access_token|refresh_token|id_token|code|code_verifier))"\s*:\s*"[^"]*"/gi,
|
||||
'"$1":"[redacted]"',
|
||||
)
|
||||
.replace(
|
||||
/\b(access_token|refresh_token|id_token|code|code_verifier)=([^&\s]+)/gi,
|
||||
'$1=[redacted]',
|
||||
)
|
||||
.slice(0, TOKEN_ERROR_BODY_LIMIT)
|
||||
}
|
||||
|
||||
function parseJwtClaims(token?: string): GrokJwtClaims | undefined {
|
||||
if (!token) return undefined
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return undefined
|
||||
try {
|
||||
return JSON.parse(Buffer.from(parts[1]!, 'base64url').toString())
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeGrokTokens(
|
||||
response: GrokOAuthTokenResponse,
|
||||
): GrokOAuthTokens {
|
||||
if (!response.access_token) {
|
||||
throw new Error('Grok OAuth response did not include an access token')
|
||||
}
|
||||
if (!response.refresh_token) {
|
||||
throw new Error('Grok OAuth response did not include a refresh token')
|
||||
}
|
||||
const claims =
|
||||
parseJwtClaims(response.id_token) ?? parseJwtClaims(response.access_token)
|
||||
return {
|
||||
accessToken: response.access_token,
|
||||
refreshToken: response.refresh_token,
|
||||
expiresAt:
|
||||
Date.now() +
|
||||
(response.expires_in ?? DEFAULT_TOKEN_LIFETIME_SECONDS) * 1000,
|
||||
...(response.id_token && { idToken: response.id_token }),
|
||||
...(claims?.email && { email: claims.email }),
|
||||
clientId: GROK_OAUTH_CLIENT_ID,
|
||||
...(response.scope && { scope: response.scope }),
|
||||
tokenType: response.token_type || 'Bearer',
|
||||
}
|
||||
}
|
||||
|
||||
export function withRefreshedGrokAccessToken(
|
||||
existing: GrokOAuthTokens,
|
||||
response: GrokOAuthTokenResponse,
|
||||
): GrokOAuthTokens {
|
||||
const claims =
|
||||
parseJwtClaims(response.id_token) ?? parseJwtClaims(response.access_token)
|
||||
return {
|
||||
...existing,
|
||||
accessToken: response.access_token,
|
||||
refreshToken: response.refresh_token ?? existing.refreshToken,
|
||||
expiresAt:
|
||||
Date.now() +
|
||||
(response.expires_in ?? DEFAULT_TOKEN_LIFETIME_SECONDS) * 1000,
|
||||
idToken: response.id_token ?? existing.idToken,
|
||||
email: claims?.email ?? existing.email,
|
||||
clientId: existing.clientId ?? GROK_OAUTH_CLIENT_ID,
|
||||
scope: response.scope ?? existing.scope,
|
||||
tokenType: response.token_type ?? existing.tokenType ?? 'Bearer',
|
||||
}
|
||||
}
|
||||
|
||||
export function isGrokTokenExpired(expiresAt: number): boolean {
|
||||
return expiresAt - Date.now() <= TOKEN_EXPIRY_SKEW_MS
|
||||
}
|
||||
177
src/services/grokAuth/fetch.test.ts
Normal file
177
src/services/grokAuth/fetch.test.ts
Normal file
@ -0,0 +1,177 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import {
|
||||
buildGrokFetch,
|
||||
GROK_CLI_API_ENDPOINT,
|
||||
GROK_CLI_VERSION,
|
||||
} from './fetch.js'
|
||||
import { GROK_OAUTH_FILE_ENV_KEY } from './storage.js'
|
||||
import { GROK_OAUTH_TOKEN_ENDPOINT } from './client.js'
|
||||
|
||||
describe('Grok Responses fetch adapter', () => {
|
||||
let tmpDir: string
|
||||
let original: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grok-fetch-'))
|
||||
original = process.env[GROK_OAUTH_FILE_ENV_KEY]
|
||||
process.env[GROK_OAUTH_FILE_ENV_KEY] = path.join(tmpDir, 'tokens.json')
|
||||
await fs.writeFile(process.env[GROK_OAUTH_FILE_ENV_KEY], JSON.stringify({
|
||||
accessToken: 'access',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: Date.now() + 3_600_000,
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (original === undefined) delete process.env[GROK_OAUTH_FILE_ENV_KEY]
|
||||
else process.env[GROK_OAUTH_FILE_ENV_KEY] = original
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('maps Anthropic messages to the exact subscription endpoint and identity', async () => {
|
||||
let call: { url: string; headers: Headers; body: Record<string, unknown> } | undefined
|
||||
const fetchOverride: typeof fetch = async (input, init) => {
|
||||
call = {
|
||||
url: String(input),
|
||||
headers: new Headers(init?.headers),
|
||||
body: JSON.parse(String(init?.body)),
|
||||
}
|
||||
return new Response([
|
||||
'event: response.completed',
|
||||
'data: {"response":{"id":"resp_1","object":"response","created_at":1,"model":"grok-4.5","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}',
|
||||
'',
|
||||
].join('\n'), { headers: { 'Content-Type': 'text/event-stream' } })
|
||||
}
|
||||
const grokFetch = buildGrokFetch(fetchOverride, 'test')
|
||||
const response = await grokFetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
model: 'grok-4.5', max_tokens: 64,
|
||||
output_config: { effort: 'max' },
|
||||
messages: [{ role: 'user', content: 'Say ok' }],
|
||||
}),
|
||||
})
|
||||
|
||||
expect(call?.url).toBe(GROK_CLI_API_ENDPOINT)
|
||||
expect(call?.headers.get('Authorization')).toBe('Bearer access')
|
||||
expect(call?.headers.get('X-XAI-Token-Auth')).toBe('xai-grok-cli')
|
||||
expect(call?.headers.get('x-grok-client-version')).toBe(GROK_CLI_VERSION)
|
||||
expect(call?.headers.get('User-Agent')).toBe(`xai-grok-workspace/${GROK_CLI_VERSION}`)
|
||||
expect(call?.headers.get('x-grok-model-override')).toBe('grok-4.5')
|
||||
expect(call?.body.model).toBe('grok-4.5')
|
||||
expect(call?.body.reasoning).toEqual({ effort: 'high' })
|
||||
expect(call?.body.stream).toBe(true)
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
type: 'message', content: [{ type: 'text', text: 'ok' }],
|
||||
})
|
||||
})
|
||||
|
||||
test('drops Claude reasoning effort for Grok models that reject it', async () => {
|
||||
let upstreamBody: Record<string, unknown> | undefined
|
||||
const fetchOverride: typeof fetch = async (_input, init) => {
|
||||
upstreamBody = JSON.parse(String(init?.body))
|
||||
return new Response([
|
||||
'event: response.completed',
|
||||
'data: {"response":{"id":"resp_no_effort","object":"response","created_at":1,"model":"grok-composer-2.5-fast","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}',
|
||||
'',
|
||||
].join('\n'), { headers: { 'Content-Type': 'text/event-stream' } })
|
||||
}
|
||||
|
||||
const response = await buildGrokFetch(fetchOverride, 'test')(
|
||||
'https://api.anthropic.com/v1/messages',
|
||||
{ method: 'POST', body: JSON.stringify({
|
||||
model: 'grok-composer-2.5-fast',
|
||||
max_tokens: 64,
|
||||
output_config: { effort: 'max' },
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}) },
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(upstreamBody?.reasoning).toBeUndefined()
|
||||
})
|
||||
|
||||
test('translates subscription SSE back to Anthropic streaming events', async () => {
|
||||
const fetchOverride: typeof fetch = async () => new Response([
|
||||
'event: response.created',
|
||||
'data: {"id":"resp_2","object":"response","created_at":1,"model":"grok-4.5","status":"in_progress"}',
|
||||
'',
|
||||
'event: response.content_part.added',
|
||||
'data: {"output_index":0,"content_index":0,"part":{"type":"output_text","text":""}}',
|
||||
'',
|
||||
'event: response.output_text.delta',
|
||||
'data: {"output_index":0,"content_index":0,"delta":"hello"}',
|
||||
'',
|
||||
'event: response.output_text.done',
|
||||
'data: {"output_index":0,"content_index":0,"text":"hello"}',
|
||||
'',
|
||||
'event: response.completed',
|
||||
'data: {"response":{"id":"resp_2","object":"response","created_at":1,"model":"grok-4.5","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}',
|
||||
'',
|
||||
].join('\n'), { headers: { 'Content-Type': 'text/event-stream' } })
|
||||
const response = await buildGrokFetch(fetchOverride, 'test')(
|
||||
'https://api.anthropic.com/v1/messages',
|
||||
{ method: 'POST', body: JSON.stringify({
|
||||
model: 'claude-opus-4-1', max_tokens: 64, stream: true,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}) },
|
||||
)
|
||||
expect(response.headers.get('Content-Type')).toContain('text/event-stream')
|
||||
expect(await response.text()).toContain('text_delta')
|
||||
})
|
||||
|
||||
test('refreshes once after a 401, persists rotation, and retries with the new access token', async () => {
|
||||
const inferenceAuth: string[] = []
|
||||
const fetchOverride: typeof fetch = async (input, init) => {
|
||||
if (String(input) === GROK_OAUTH_TOKEN_ENDPOINT) {
|
||||
return Response.json({
|
||||
access_token: 'new-access',
|
||||
refresh_token: 'new-refresh',
|
||||
expires_in: 3600,
|
||||
})
|
||||
}
|
||||
inferenceAuth.push(new Headers(init?.headers).get('Authorization') ?? '')
|
||||
if (inferenceAuth.length === 1) return new Response('expired', { status: 401 })
|
||||
return new Response([
|
||||
'event: response.completed',
|
||||
'data: {"response":{"id":"resp_retry","object":"response","created_at":1,"model":"grok-4.5","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}',
|
||||
'',
|
||||
].join('\n'), { headers: { 'Content-Type': 'text/event-stream' } })
|
||||
}
|
||||
|
||||
const response = await buildGrokFetch(fetchOverride, 'test')(
|
||||
'https://api.anthropic.com/v1/messages',
|
||||
{ method: 'POST', body: JSON.stringify({
|
||||
model: 'grok-4.5', max_tokens: 64,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}) },
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(inferenceAuth).toEqual(['Bearer access', 'Bearer new-access'])
|
||||
expect(JSON.parse(await fs.readFile(process.env[GROK_OAUTH_FILE_ENV_KEY]!, 'utf8'))).toMatchObject({
|
||||
accessToken: 'new-access',
|
||||
refreshToken: 'new-refresh',
|
||||
})
|
||||
})
|
||||
|
||||
test('does not refresh-loop on entitlement failures', async () => {
|
||||
let calls = 0
|
||||
const response = await buildGrokFetch(async () => {
|
||||
calls += 1
|
||||
return new Response('subscription required', { status: 403 })
|
||||
}, 'test')(
|
||||
'https://api.anthropic.com/v1/messages',
|
||||
{ method: 'POST', body: JSON.stringify({
|
||||
model: 'grok-4.5', max_tokens: 64,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}) },
|
||||
)
|
||||
expect(response.status).toBe(403)
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
})
|
||||
134
src/services/grokAuth/fetch.ts
Normal file
134
src/services/grokAuth/fetch.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import { anthropicToOpenaiResponses } from '../../server/proxy/transform/anthropicToOpenaiResponses.js'
|
||||
import { openaiResponsesStreamToAnthropic } from '../../server/proxy/streaming/openaiResponsesStreamToAnthropic.js'
|
||||
import { openaiResponsesStreamToAnthropicResponse } from '../../server/proxy/streaming/openaiResponsesStreamToAnthropicResponse.js'
|
||||
import type { AnthropicRequest } from '../../server/proxy/transform/types.js'
|
||||
import { ensureFreshGrokTokens, forceRefreshGrokTokens } from './refresh.js'
|
||||
import { grokModelRejectsReasoningEffort, resolveGrokModel } from './models.js'
|
||||
import { getGrokOAuthTokens } from './storage.js'
|
||||
|
||||
export const GROK_CLI_BASE_URL = 'https://cli-chat-proxy.grok.com/v1'
|
||||
export const GROK_CLI_API_ENDPOINT = `${GROK_CLI_BASE_URL}/responses`
|
||||
export const GROK_CLI_VERSION = '0.2.99'
|
||||
export const GROK_OAUTH_DUMMY_KEY = 'grok-oauth-dummy-key'
|
||||
|
||||
export function shouldUseGrokAuth(): boolean {
|
||||
return !!getGrokOAuthTokens()?.refreshToken
|
||||
}
|
||||
|
||||
export function buildGrokIdentityHeaders(accessToken: string): Headers {
|
||||
return new Headers({
|
||||
Accept: 'application/json, text/event-stream',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-XAI-Token-Auth': 'xai-grok-cli',
|
||||
'x-grok-client-version': GROK_CLI_VERSION,
|
||||
'User-Agent': `xai-grok-workspace/${GROK_CLI_VERSION}`,
|
||||
})
|
||||
}
|
||||
|
||||
export function buildGrokFetch(
|
||||
fetchOverride: typeof fetch | undefined,
|
||||
source: string | undefined,
|
||||
): typeof fetch {
|
||||
const inner = fetchOverride ?? globalThis.fetch
|
||||
|
||||
return async (input, init) => {
|
||||
const url = input instanceof Request ? new URL(input.url) : new URL(String(input))
|
||||
if (!url.pathname.endsWith('/v1/messages')) return inner(input, init)
|
||||
|
||||
const originalBody = await readAnthropicBody(input, init)
|
||||
const requestedModel = resolveGrokModel(originalBody.model)
|
||||
const transformedBody = anthropicToOpenaiResponses({
|
||||
...originalBody,
|
||||
model: requestedModel,
|
||||
})
|
||||
transformedBody.model = requestedModel
|
||||
transformedBody.stream = true
|
||||
if (grokModelRejectsReasoningEffort(requestedModel)) {
|
||||
delete transformedBody.reasoning
|
||||
}
|
||||
|
||||
const tokens = await ensureFreshGrokTokens({ fetchOverride: inner })
|
||||
if (!tokens) {
|
||||
throw new Error(
|
||||
'Grok OAuth token is missing or expired. Authorize Grok again in the desktop app.',
|
||||
)
|
||||
}
|
||||
const headers = buildGrokIdentityHeaders(tokens.accessToken)
|
||||
headers.set('x-grok-model-override', requestedModel)
|
||||
|
||||
void source
|
||||
const requestUpstream = (requestHeaders: Headers) => inner(GROK_CLI_API_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: requestHeaders,
|
||||
body: JSON.stringify(transformedBody),
|
||||
signal: init?.signal,
|
||||
})
|
||||
let upstream = await requestUpstream(headers)
|
||||
if (upstream.status === 401) {
|
||||
const refreshed = await forceRefreshGrokTokens({ fetchOverride: inner })
|
||||
if (refreshed) {
|
||||
const refreshedHeaders = buildGrokIdentityHeaders(refreshed.accessToken)
|
||||
refreshedHeaders.set('x-grok-model-override', requestedModel)
|
||||
upstream = await requestUpstream(refreshedHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
if (!upstream.ok) {
|
||||
const errorText = await upstream.text().catch(() => '')
|
||||
return Response.json(
|
||||
{
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'api_error',
|
||||
message: `Grok upstream returned HTTP ${upstream.status}: ${errorText.slice(0, 500)}`,
|
||||
},
|
||||
},
|
||||
{ status: upstream.status },
|
||||
)
|
||||
}
|
||||
if (!upstream.body) {
|
||||
return Response.json(
|
||||
{ type: 'error', error: { type: 'api_error', message: 'Grok upstream returned no stream body' } },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
if (originalBody.stream) {
|
||||
return new Response(
|
||||
openaiResponsesStreamToAnthropic(upstream.body, requestedModel),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
await openaiResponsesStreamToAnthropicResponse(
|
||||
upstream.body,
|
||||
requestedModel,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function readAnthropicBody(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<AnthropicRequest> {
|
||||
if (typeof init?.body === 'string') {
|
||||
return JSON.parse(init.body) as AnthropicRequest
|
||||
}
|
||||
if (init?.body instanceof Uint8Array || init?.body instanceof ArrayBuffer) {
|
||||
return JSON.parse(Buffer.from(init.body).toString('utf8')) as AnthropicRequest
|
||||
}
|
||||
if (input instanceof Request) {
|
||||
return (await input.clone().json()) as AnthropicRequest
|
||||
}
|
||||
throw new Error('Unable to read Anthropic request body for Grok transformation')
|
||||
}
|
||||
7
src/services/grokAuth/index.ts
Normal file
7
src/services/grokAuth/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export * from './client.js'
|
||||
export * from './fetch.js'
|
||||
export * from './modelCatalog.js'
|
||||
export * from './models.js'
|
||||
export * from './refresh.js'
|
||||
export * from './storage.js'
|
||||
export type * from './types.js'
|
||||
83
src/services/grokAuth/modelCatalog.test.ts
Normal file
83
src/services/grokAuth/modelCatalog.test.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import {
|
||||
fetchGrokModelCatalog,
|
||||
getGrokModelCatalog,
|
||||
GROK_MODELS_ENDPOINT,
|
||||
} from './modelCatalog.js'
|
||||
import { GROK_CLI_VERSION } from './fetch.js'
|
||||
import { GROK_OAUTH_FILE_ENV_KEY } from './storage.js'
|
||||
|
||||
describe('Grok model catalog', () => {
|
||||
let tmpDir: string
|
||||
let original: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grok-models-'))
|
||||
original = process.env[GROK_OAUTH_FILE_ENV_KEY]
|
||||
process.env[GROK_OAUTH_FILE_ENV_KEY] = path.join(tmpDir, 'tokens.json')
|
||||
await fs.writeFile(process.env[GROK_OAUTH_FILE_ENV_KEY], JSON.stringify({
|
||||
accessToken: 'access', refreshToken: 'refresh', expiresAt: Date.now() + 3_600_000,
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (original === undefined) delete process.env[GROK_OAUTH_FILE_ENV_KEY]
|
||||
else process.env[GROK_OAUTH_FILE_ENV_KEY] = original
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('normalizes the official models response and filters non-API entries', async () => {
|
||||
let seen: { url: string; headers: Headers } | undefined
|
||||
const models = await fetchGrokModelCatalog(async (input, init) => {
|
||||
seen = { url: String(input), headers: new Headers(init?.headers) }
|
||||
return Response.json({ models: {
|
||||
build: {
|
||||
info: {
|
||||
id: 'grok-build',
|
||||
name: 'Grok Build',
|
||||
context_window: 500000,
|
||||
supported_in_api: false,
|
||||
},
|
||||
},
|
||||
frontier: {
|
||||
info: {
|
||||
id: 'grok-4.5',
|
||||
name: 'Grok 4.5',
|
||||
context_window: 500000,
|
||||
supported_in_api: true,
|
||||
supports_reasoning_effort: true,
|
||||
reasoning_effort: 'high',
|
||||
reasoning_efforts: [
|
||||
{ value: 'high', default: true },
|
||||
{ value: 'medium' },
|
||||
{ value: 'low' },
|
||||
],
|
||||
},
|
||||
},
|
||||
} })
|
||||
})
|
||||
expect(seen?.url).toBe(GROK_MODELS_ENDPOINT)
|
||||
expect(seen?.headers.get('Authorization')).toBe('Bearer access')
|
||||
expect(seen?.headers.get('x-grok-client-version')).toBe(GROK_CLI_VERSION)
|
||||
expect(GROK_MODELS_ENDPOINT).toEndWith('/v1/models')
|
||||
expect(models.map((model) => model.value)).toEqual(['grok-4.5'])
|
||||
expect(models[0]).toMatchObject({
|
||||
contextWindow: 500000,
|
||||
supportsReasoningEffort: true,
|
||||
reasoningEffort: 'high',
|
||||
reasoningEfforts: ['high', 'medium', 'low'],
|
||||
})
|
||||
})
|
||||
|
||||
test('falls back to the static public catalog on failure', async () => {
|
||||
const models = await getGrokModelCatalog({
|
||||
forceRefresh: true,
|
||||
fetchOverride: async () => new Response('nope', { status: 503 }),
|
||||
})
|
||||
expect(models[0]?.value).toBe('grok-4.5')
|
||||
expect(models.some((model) => model.value === 'grok-4.5')).toBe(true)
|
||||
})
|
||||
})
|
||||
166
src/services/grokAuth/modelCatalog.ts
Normal file
166
src/services/grokAuth/modelCatalog.ts
Normal file
@ -0,0 +1,166 @@
|
||||
import {
|
||||
buildGrokIdentityHeaders,
|
||||
GROK_CLI_BASE_URL,
|
||||
} from './fetch.js'
|
||||
import { ensureFreshGrokTokens } from './refresh.js'
|
||||
import {
|
||||
GROK_DEFAULT_CONTEXT_WINDOW,
|
||||
GROK_MODEL_CATALOG,
|
||||
type GrokModelCatalogEntry,
|
||||
} from './models.js'
|
||||
|
||||
export const GROK_MODELS_ENDPOINT = `${GROK_CLI_BASE_URL}/models`
|
||||
const MODEL_CATALOG_TTL_MS = 5 * 60_000
|
||||
let cachedCatalog: {
|
||||
accountKey: string
|
||||
expiresAt: number
|
||||
models: GrokModelCatalogEntry[]
|
||||
} | null = null
|
||||
|
||||
export async function fetchGrokModelCatalog(
|
||||
fetchOverride: typeof fetch = globalThis.fetch,
|
||||
accessToken?: string,
|
||||
): Promise<GrokModelCatalogEntry[]> {
|
||||
const token = accessToken || (await ensureFreshGrokTokens({ fetchOverride }))?.accessToken
|
||||
if (!token) throw new Error('Grok OAuth token is unavailable')
|
||||
const response = await fetchOverride(GROK_MODELS_ENDPOINT, {
|
||||
method: 'GET',
|
||||
headers: buildGrokIdentityHeaders(token),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Grok models endpoint returned HTTP ${response.status}`)
|
||||
}
|
||||
const body = await response.json() as unknown
|
||||
const rows = extractModelRows(body)
|
||||
const models = rows
|
||||
.map(normalizeRemoteModel)
|
||||
.filter((model): model is GrokModelCatalogEntry => model !== null)
|
||||
if (!models.length) throw new Error('Grok models endpoint returned no models')
|
||||
return models
|
||||
}
|
||||
|
||||
export async function getGrokModelCatalog(options?: {
|
||||
fetchOverride?: typeof fetch
|
||||
forceRefresh?: boolean
|
||||
accessToken?: string
|
||||
accountKey?: string
|
||||
}): Promise<GrokModelCatalogEntry[]> {
|
||||
const accountKey = options?.accountKey ?? (options?.accessToken ? 'authenticated' : 'default')
|
||||
if (
|
||||
!options?.forceRefresh &&
|
||||
cachedCatalog?.accountKey === accountKey &&
|
||||
cachedCatalog.expiresAt > Date.now()
|
||||
) {
|
||||
return cachedCatalog.models
|
||||
}
|
||||
try {
|
||||
const models = await fetchGrokModelCatalog(
|
||||
options?.fetchOverride,
|
||||
options?.accessToken,
|
||||
)
|
||||
cachedCatalog = {
|
||||
accountKey,
|
||||
expiresAt: Date.now() + MODEL_CATALOG_TTL_MS,
|
||||
models,
|
||||
}
|
||||
return models
|
||||
} catch {
|
||||
return GROK_MODEL_CATALOG
|
||||
}
|
||||
}
|
||||
|
||||
export function clearGrokModelCatalogCache(): void {
|
||||
cachedCatalog = null
|
||||
}
|
||||
|
||||
function extractModelRows(body: unknown): unknown[] {
|
||||
if (Array.isArray(body)) return body
|
||||
if (!body || typeof body !== 'object') return []
|
||||
const record = body as Record<string, unknown>
|
||||
if (Array.isArray(record.models)) return record.models
|
||||
if (isKeyedObject(record.models)) return keyedModelRows(record.models)
|
||||
if (Array.isArray(record.data)) return record.data
|
||||
if (isKeyedObject(record.data)) return keyedModelRows(record.data)
|
||||
return keyedModelRows(record)
|
||||
}
|
||||
|
||||
function keyedModelRows(record: Record<string, unknown>): unknown[] {
|
||||
return Object.entries(record)
|
||||
.filter(([, value]) => value && typeof value === 'object' && !Array.isArray(value))
|
||||
.map(([key, value]) => ({ ...(value as Record<string, unknown>), __key: key }))
|
||||
}
|
||||
|
||||
function normalizeRemoteModel(value: unknown): GrokModelCatalogEntry | null {
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const outer = value as Record<string, unknown>
|
||||
const meta = isKeyedObject(outer.meta) ? outer.meta : {}
|
||||
const info = isKeyedObject(outer.info) ? outer.info : {}
|
||||
const record = { ...outer, ...meta, ...info }
|
||||
if (record.hidden === true || record.supported_in_api === false || record.supportedInApi === false) {
|
||||
return null
|
||||
}
|
||||
const id = firstString(record.id, record.slug, record.model, record.value, record.__key)
|
||||
if (!id) return null
|
||||
const fallback = GROK_MODEL_CATALOG.find((model) => model.value === id)
|
||||
const label = firstString(record.display_name, record.displayName, record.name, record.label) ?? fallback?.label ?? id
|
||||
const description = firstString(record.description) ?? fallback?.description ?? ''
|
||||
const contextWindow = firstNumber(
|
||||
record.totalContextTokens,
|
||||
record.total_context_tokens,
|
||||
record.context_window,
|
||||
record.contextWindow,
|
||||
) ?? fallback?.contextWindow ?? GROK_DEFAULT_CONTEXT_WINDOW
|
||||
const supportsReasoningEffort = firstBoolean(
|
||||
record.supportsReasoningEffort,
|
||||
record.supports_reasoning_effort,
|
||||
)
|
||||
const reasoningEffort = firstString(
|
||||
record.reasoningEffort,
|
||||
record.reasoning_effort,
|
||||
)
|
||||
const reasoningEfforts = firstStringArray(
|
||||
record.reasoningEfforts,
|
||||
record.reasoning_efforts,
|
||||
)
|
||||
return {
|
||||
value: id,
|
||||
label,
|
||||
description,
|
||||
contextWindow,
|
||||
source: fallback?.source ?? 'official',
|
||||
...(supportsReasoningEffort !== undefined && { supportsReasoningEffort }),
|
||||
...(reasoningEffort && { reasoningEffort }),
|
||||
...(reasoningEfforts && { reasoningEfforts }),
|
||||
}
|
||||
}
|
||||
|
||||
function firstString(...values: unknown[]): string | undefined {
|
||||
return values.find((value): value is string => typeof value === 'string' && !!value.trim())?.trim()
|
||||
}
|
||||
|
||||
function firstNumber(...values: unknown[]): number | undefined {
|
||||
return values.find((value): value is number => typeof value === 'number' && Number.isFinite(value) && value > 0)
|
||||
}
|
||||
|
||||
function firstBoolean(...values: unknown[]): boolean | undefined {
|
||||
return values.find((value): value is boolean => typeof value === 'boolean')
|
||||
}
|
||||
|
||||
function firstStringArray(...values: unknown[]): string[] | undefined {
|
||||
for (const value of values) {
|
||||
if (!Array.isArray(value)) continue
|
||||
const strings = value.flatMap((item) => {
|
||||
if (typeof item === 'string' && item.trim()) return [item.trim()]
|
||||
if (!isKeyedObject(item)) return []
|
||||
const candidate = firstString(item.value, item.id)
|
||||
return candidate ? [candidate] : []
|
||||
})
|
||||
if (strings.length) return strings
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isKeyedObject(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
26
src/services/grokAuth/models.test.ts
Normal file
26
src/services/grokAuth/models.test.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
GROK_DEFAULT_MAIN_MODEL,
|
||||
GROK_MODEL_CATALOG,
|
||||
getGrokContextWindowForModel,
|
||||
resolveGrokModel,
|
||||
} from './models.js'
|
||||
|
||||
describe('Grok model catalog', () => {
|
||||
test('keeps CLI-only aliases out of the picker fallback and defaults to Grok 4.5', () => {
|
||||
expect(GROK_MODEL_CATALOG.map((model) => model.value)).toEqual([
|
||||
'grok-4.5',
|
||||
'grok-composer-2.5-fast',
|
||||
])
|
||||
expect(GROK_DEFAULT_MAIN_MODEL).toBe('grok-4.5')
|
||||
expect(resolveGrokModel('claude-opus-4-1')).toBe(GROK_DEFAULT_MAIN_MODEL)
|
||||
})
|
||||
|
||||
test('preserves explicit model IDs and resolves supported aliases', () => {
|
||||
expect(resolveGrokModel('grok-composer-2.5-fast')).toBe('grok-composer-2.5-fast')
|
||||
expect(resolveGrokModel('grok')).toBe(GROK_DEFAULT_MAIN_MODEL)
|
||||
expect(resolveGrokModel('unknown-model')).toBe(GROK_DEFAULT_MAIN_MODEL)
|
||||
expect(getGrokContextWindowForModel('grok-4.5')).toBe(500_000)
|
||||
expect(getGrokContextWindowForModel('unknown-model')).toBe(500_000)
|
||||
})
|
||||
})
|
||||
56
src/services/grokAuth/models.ts
Normal file
56
src/services/grokAuth/models.ts
Normal file
@ -0,0 +1,56 @@
|
||||
export const GROK_DEFAULT_MAIN_MODEL = 'grok-4.5'
|
||||
export const GROK_DEFAULT_SONNET_MODEL = GROK_DEFAULT_MAIN_MODEL
|
||||
export const GROK_DEFAULT_HAIKU_MODEL = GROK_DEFAULT_MAIN_MODEL
|
||||
export const GROK_DEFAULT_MODEL = GROK_DEFAULT_MAIN_MODEL
|
||||
export const GROK_DEFAULT_CONTEXT_WINDOW = 500_000
|
||||
|
||||
export type GrokModelCatalogEntry = {
|
||||
value: string
|
||||
label: string
|
||||
description: string
|
||||
contextWindow?: number
|
||||
source?: 'official' | 'cli'
|
||||
supportsReasoningEffort?: boolean
|
||||
reasoningEffort?: string
|
||||
reasoningEfforts?: string[]
|
||||
}
|
||||
|
||||
export const GROK_MODEL_CATALOG: GrokModelCatalogEntry[] = [
|
||||
{
|
||||
...model('grok-4.5', 'Grok 4.5', 'Grok frontier text model', 500_000),
|
||||
supportsReasoningEffort: true,
|
||||
reasoningEffort: 'high',
|
||||
reasoningEfforts: ['high', 'medium', 'low'],
|
||||
},
|
||||
{
|
||||
...model('grok-composer-2.5-fast', 'Composer 2.5', 'Grok coding model', 200_000),
|
||||
supportsReasoningEffort: false,
|
||||
},
|
||||
]
|
||||
|
||||
function model(
|
||||
value: string,
|
||||
label: string,
|
||||
description: string,
|
||||
contextWindow: number,
|
||||
source: 'official' | 'cli' = 'official',
|
||||
): GrokModelCatalogEntry {
|
||||
return { value, label, description, contextWindow, source }
|
||||
}
|
||||
|
||||
const EXPLICIT_MODELS = new Set(GROK_MODEL_CATALOG.map((entry) => entry.value))
|
||||
|
||||
export function resolveGrokModel(modelId: string): string {
|
||||
const normalized = modelId.trim().toLowerCase()
|
||||
return EXPLICIT_MODELS.has(normalized) ? normalized : GROK_DEFAULT_MAIN_MODEL
|
||||
}
|
||||
|
||||
export function getGrokContextWindowForModel(modelId: string): number | null {
|
||||
const resolved = resolveGrokModel(modelId)
|
||||
return GROK_MODEL_CATALOG.find((model) => model.value === resolved)?.contextWindow ?? null
|
||||
}
|
||||
|
||||
export function grokModelRejectsReasoningEffort(modelId: string): boolean {
|
||||
const resolved = resolveGrokModel(modelId)
|
||||
return resolved === 'grok-composer-2.5-fast'
|
||||
}
|
||||
50
src/services/grokAuth/refresh.test.ts
Normal file
50
src/services/grokAuth/refresh.test.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import { clearFreshGrokTokenCache, ensureFreshGrokTokens } from './refresh.js'
|
||||
import { GROK_OAUTH_FILE_ENV_KEY } from './storage.js'
|
||||
|
||||
describe('Grok token refresh helper', () => {
|
||||
let tmpDir: string
|
||||
let tokenFile: string
|
||||
let original: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grok-refresh-'))
|
||||
tokenFile = path.join(tmpDir, 'tokens.json')
|
||||
original = process.env[GROK_OAUTH_FILE_ENV_KEY]
|
||||
process.env[GROK_OAUTH_FILE_ENV_KEY] = tokenFile
|
||||
clearFreshGrokTokenCache()
|
||||
await fs.writeFile(tokenFile, JSON.stringify({
|
||||
accessToken: 'expired', refreshToken: 'old-refresh', expiresAt: 1,
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
clearFreshGrokTokenCache()
|
||||
if (original === undefined) delete process.env[GROK_OAUTH_FILE_ENV_KEY]
|
||||
else process.env[GROK_OAUTH_FILE_ENV_KEY] = original
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('refreshes once and persists a rotated refresh token', async () => {
|
||||
let calls = 0
|
||||
const fetchOverride: typeof fetch = async () => {
|
||||
calls++
|
||||
return Response.json({
|
||||
access_token: 'fresh-access', refresh_token: 'rotated-refresh', expires_in: 3600,
|
||||
})
|
||||
}
|
||||
await expect(ensureFreshGrokTokens({ fetchOverride })).resolves.toMatchObject({
|
||||
accessToken: 'fresh-access', refreshToken: 'rotated-refresh',
|
||||
})
|
||||
await expect(ensureFreshGrokTokens({ fetchOverride })).resolves.toMatchObject({
|
||||
accessToken: 'fresh-access', refreshToken: 'rotated-refresh',
|
||||
})
|
||||
expect(calls).toBe(1)
|
||||
expect(JSON.parse(await fs.readFile(tokenFile, 'utf8'))).toMatchObject({
|
||||
accessToken: 'fresh-access', refreshToken: 'rotated-refresh',
|
||||
})
|
||||
})
|
||||
})
|
||||
69
src/services/grokAuth/refresh.ts
Normal file
69
src/services/grokAuth/refresh.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import {
|
||||
isGrokTokenExpired,
|
||||
refreshGrokTokens,
|
||||
withRefreshedGrokAccessToken,
|
||||
type GrokTokenFetchOptions,
|
||||
} from './client.js'
|
||||
import {
|
||||
getGrokOAuthTokenFilePath,
|
||||
getGrokOAuthTokensAsync,
|
||||
saveGrokOAuthTokens,
|
||||
} from './storage.js'
|
||||
import type { GrokOAuthTokens } from './types.js'
|
||||
|
||||
let refreshedCache: {
|
||||
authority: string
|
||||
refreshToken: string
|
||||
tokens: GrokOAuthTokens
|
||||
} | null = null
|
||||
|
||||
export async function ensureFreshGrokTokens(
|
||||
options: GrokTokenFetchOptions = {},
|
||||
): Promise<GrokOAuthTokens | null> {
|
||||
const authority = getGrokOAuthTokenFilePath()
|
||||
if (!authority) return null
|
||||
const stored = await getGrokOAuthTokensAsync()
|
||||
if (!stored) return null
|
||||
|
||||
if (
|
||||
refreshedCache?.authority === authority &&
|
||||
refreshedCache.refreshToken === stored.refreshToken &&
|
||||
!isGrokTokenExpired(refreshedCache.tokens.expiresAt)
|
||||
) {
|
||||
return refreshedCache.tokens
|
||||
}
|
||||
if (!isGrokTokenExpired(stored.expiresAt)) return stored
|
||||
|
||||
return refreshStoredGrokTokens(authority, stored, options)
|
||||
}
|
||||
|
||||
export async function forceRefreshGrokTokens(
|
||||
options: GrokTokenFetchOptions = {},
|
||||
): Promise<GrokOAuthTokens | null> {
|
||||
const authority = getGrokOAuthTokenFilePath()
|
||||
if (!authority) return null
|
||||
const stored = await getGrokOAuthTokensAsync()
|
||||
if (!stored) return null
|
||||
return refreshStoredGrokTokens(authority, stored, options)
|
||||
}
|
||||
|
||||
async function refreshStoredGrokTokens(
|
||||
authority: string,
|
||||
stored: GrokOAuthTokens,
|
||||
options: GrokTokenFetchOptions,
|
||||
): Promise<GrokOAuthTokens> {
|
||||
const response = await refreshGrokTokens(stored.refreshToken, {
|
||||
...options,
|
||||
clientId: stored.clientId ?? options.clientId,
|
||||
})
|
||||
const tokens = withRefreshedGrokAccessToken(stored, response)
|
||||
if (!saveGrokOAuthTokens(tokens)) {
|
||||
throw new Error('Failed to persist refreshed Grok OAuth tokens')
|
||||
}
|
||||
refreshedCache = { authority, refreshToken: stored.refreshToken, tokens }
|
||||
return tokens
|
||||
}
|
||||
|
||||
export function clearFreshGrokTokenCache(): void {
|
||||
refreshedCache = null
|
||||
}
|
||||
77
src/services/grokAuth/storage.test.ts
Normal file
77
src/services/grokAuth/storage.test.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import {
|
||||
getGrokOAuthTokens,
|
||||
getGrokOAuthTokensAsync,
|
||||
GROK_OAUTH_FILE_ENV_KEY,
|
||||
saveGrokOAuthTokens,
|
||||
} from './storage.js'
|
||||
|
||||
describe('Grok OAuth desktop token file', () => {
|
||||
let tmpDir: string
|
||||
let tokenFile: string
|
||||
let original: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grok-oauth-'))
|
||||
tokenFile = path.join(tmpDir, 'tokens.json')
|
||||
original = process.env[GROK_OAUTH_FILE_ENV_KEY]
|
||||
process.env[GROK_OAUTH_FILE_ENV_KEY] = tokenFile
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (original === undefined) delete process.env[GROK_OAUTH_FILE_ENV_KEY]
|
||||
else process.env[GROK_OAUTH_FILE_ENV_KEY] = original
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('reads camelCase desktop tokens without modifying the file', async () => {
|
||||
const raw = JSON.stringify({
|
||||
accessToken: 'access',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: 4_100_000_000_000,
|
||||
email: 'user@example.com',
|
||||
})
|
||||
await fs.writeFile(tokenFile, raw)
|
||||
|
||||
expect(getGrokOAuthTokens()).toMatchObject({ accessToken: 'access' })
|
||||
await expect(getGrokOAuthTokensAsync()).resolves.toMatchObject({
|
||||
refreshToken: 'refresh',
|
||||
})
|
||||
expect(await fs.readFile(tokenFile, 'utf8')).toBe(raw)
|
||||
})
|
||||
|
||||
test('accepts xAI snake_case token response fields and rejects missing authority', async () => {
|
||||
await fs.writeFile(tokenFile, JSON.stringify({
|
||||
access_token: 'access',
|
||||
refresh_token: 'refresh',
|
||||
expires_at: '2099-01-01T00:00:00.000Z',
|
||||
token_type: 'Bearer',
|
||||
}))
|
||||
expect(getGrokOAuthTokens()).toMatchObject({
|
||||
accessToken: 'access',
|
||||
refreshToken: 'refresh',
|
||||
tokenType: 'Bearer',
|
||||
})
|
||||
|
||||
delete process.env[GROK_OAUTH_FILE_ENV_KEY]
|
||||
expect(getGrokOAuthTokens()).toBeNull()
|
||||
await expect(getGrokOAuthTokensAsync()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('atomically persists refreshed tokens for desktop and rotated refresh tokens', async () => {
|
||||
expect(saveGrokOAuthTokens({
|
||||
accessToken: 'fresh-access',
|
||||
refreshToken: 'rotated-refresh',
|
||||
expiresAt: 4_100_000_000_000,
|
||||
})).toBe(true)
|
||||
await expect(getGrokOAuthTokensAsync()).resolves.toMatchObject({
|
||||
accessToken: 'fresh-access',
|
||||
refreshToken: 'rotated-refresh',
|
||||
})
|
||||
const entries = await fs.readdir(tmpDir)
|
||||
expect(entries.filter((entry) => entry.includes('.tmp.'))).toEqual([])
|
||||
})
|
||||
})
|
||||
108
src/services/grokAuth/storage.ts
Normal file
108
src/services/grokAuth/storage.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import type { GrokOAuthTokens } from './types.js'
|
||||
|
||||
export const GROK_OAUTH_FILE_ENV_KEY = 'GROK_OAUTH_FILE'
|
||||
|
||||
export function getGrokOAuthTokenFilePath(): string | null {
|
||||
return process.env[GROK_OAUTH_FILE_ENV_KEY]?.trim() || null
|
||||
}
|
||||
|
||||
export function getGrokOAuthTokens(): GrokOAuthTokens | null {
|
||||
const filePath = getGrokOAuthTokenFilePath()
|
||||
if (!filePath) return null
|
||||
try {
|
||||
return normalizeTokenFile(JSON.parse(fs.readFileSync(filePath, 'utf8')))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGrokOAuthTokensAsync(): Promise<GrokOAuthTokens | null> {
|
||||
const filePath = getGrokOAuthTokenFilePath()
|
||||
if (!filePath) return null
|
||||
try {
|
||||
return normalizeTokenFile(
|
||||
JSON.parse(await fs.promises.readFile(filePath, 'utf8')),
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function saveGrokOAuthTokens(tokens: GrokOAuthTokens): boolean {
|
||||
const filePath = getGrokOAuthTokenFilePath()
|
||||
if (!filePath) return false
|
||||
const temporaryPath = `${filePath}.tmp.${process.pid}.${Date.now()}`
|
||||
let renamed = false
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
||||
fs.writeFileSync(temporaryPath, `${JSON.stringify(tokens, null, 2)}\n`, {
|
||||
mode: 0o600,
|
||||
})
|
||||
fs.renameSync(temporaryPath, filePath)
|
||||
renamed = true
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
if (!renamed) {
|
||||
try {
|
||||
fs.rmSync(temporaryPath, { force: true })
|
||||
} catch {
|
||||
// Best-effort cleanup; never expose token contents in an error.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTokenFile(value: unknown): GrokOAuthTokens | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||||
const record = value as Record<string, unknown>
|
||||
const accessToken = stringField(record, 'accessToken', 'access_token')
|
||||
const refreshToken = stringField(record, 'refreshToken', 'refresh_token')
|
||||
const expiresAt = expiryField(record.expiresAt ?? record.expires_at)
|
||||
if (!accessToken || !refreshToken || expiresAt === null) return null
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresAt,
|
||||
...optionalString(record, 'idToken', 'id_token'),
|
||||
...optionalString(record, 'email'),
|
||||
...optionalString(record, 'clientId', 'client_id'),
|
||||
...optionalString(record, 'scope'),
|
||||
...optionalString(record, 'tokenType', 'token_type'),
|
||||
}
|
||||
}
|
||||
|
||||
function stringField(
|
||||
record: Record<string, unknown>,
|
||||
...keys: string[]
|
||||
): string | undefined {
|
||||
for (const key of keys) {
|
||||
if (typeof record[key] === 'string' && record[key].trim()) {
|
||||
return record[key].trim()
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function optionalString(
|
||||
record: Record<string, unknown>,
|
||||
target: keyof GrokOAuthTokens,
|
||||
source = target,
|
||||
): Partial<GrokOAuthTokens> {
|
||||
const value = stringField(record, target, source)
|
||||
return value ? { [target]: value } : {}
|
||||
}
|
||||
|
||||
function expiryField(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const numeric = Number(value)
|
||||
if (Number.isFinite(numeric)) return numeric
|
||||
const parsed = Date.parse(value)
|
||||
if (Number.isFinite(parsed)) return parsed
|
||||
}
|
||||
return null
|
||||
}
|
||||
23
src/services/grokAuth/types.ts
Normal file
23
src/services/grokAuth/types.ts
Normal file
@ -0,0 +1,23 @@
|
||||
export type GrokOAuthTokenResponse = {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
id_token?: string
|
||||
expires_in?: number
|
||||
scope?: string
|
||||
token_type?: string
|
||||
}
|
||||
|
||||
export type GrokOAuthTokens = {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiresAt: number
|
||||
idToken?: string
|
||||
email?: string
|
||||
clientId?: string
|
||||
scope?: string
|
||||
tokenType?: string
|
||||
}
|
||||
|
||||
export type GrokJwtClaims = {
|
||||
email?: string
|
||||
}
|
||||
@ -34,7 +34,7 @@ export class AuthCodeListener {
|
||||
* This avoids race conditions by keeping the server open until it's used.
|
||||
* @param port Optional specific port to use. If not provided, uses OS-assigned port.
|
||||
*/
|
||||
async start(port?: number): Promise<number> {
|
||||
async start(port?: number, host = 'localhost'): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.localServer.once('error', err => {
|
||||
reject(
|
||||
@ -43,7 +43,7 @@ export class AuthCodeListener {
|
||||
})
|
||||
|
||||
// Listen on specified port or 0 to let the OS assign an available port
|
||||
this.localServer.listen(port ?? 0, 'localhost', () => {
|
||||
this.localServer.listen(port ?? 0, host, () => {
|
||||
const address = this.localServer.address() as AddressInfo
|
||||
this.port = address.port
|
||||
resolve(this.port)
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user