Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 98 additions & 1 deletion src/commands/provider/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from '../../components/CustomSelect/index.js'
import { Dialog } from '../../components/design-system/Dialog.js'
import { LoadingState } from '../../components/design-system/LoadingState.js'
import { useCodexDeviceCodeFlow } from '../../components/useCodexDeviceCodeFlow.js'
import { useCodexOAuthFlow } from '../../components/useCodexOAuthFlow.js'
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
import { Box, Text } from '../../ink.js'
Expand Down Expand Up @@ -135,7 +136,7 @@ function describeOllamaReadinessIssue(
return ''
}

type ProviderChoice = 'auto' | ProviderProfile | 'codex-oauth' | 'clear'
type ProviderChoice = 'auto' | ProviderProfile | 'codex-oauth' | 'codex-device-code' | 'clear'

type Step =
| { name: 'choose' }
Expand Down Expand Up @@ -167,6 +168,7 @@ type Step =
authMode: 'api-key' | 'access-token' | 'adc'
}
| { name: 'codex-oauth' }
| { name: 'codex-device-code' }
| { name: 'codex-check' }

type CurrentProviderSummary = {
Expand Down Expand Up @@ -713,6 +715,12 @@ function ProviderChooser({
description:
'Sign in with ChatGPT in your browser and store Codex tokens securely',
},
{
label: 'Codex Device Code',
value: 'codex-device-code' as const,
description:
'Sign in with a device code and store Codex tokens securely',
},
]
: []),
]
Expand Down Expand Up @@ -1098,6 +1106,84 @@ function OllamaModelStep({
)
}


function CodexDeviceCodeStep({
onSave,
onBack,
onCancel,
}: {
onSave: (profile: ProviderProfile, env: ProfileEnv) => void
onBack: () => void
onCancel: () => void
}): React.ReactNode {
const handleAuthenticated = React.useCallback(async (
tokens: CodexOAuthTokens,
persistCredentials: (options?: { profileId?: string }) => void,
) => {
const env = buildCodexOAuthProfileEnv(tokens)
if (!env) {
throw new Error(
'Codex device-code sign-in succeeded, but OpenClaude could not build a Codex profile from the stored credentials.',
)
}

persistCredentials()
onSave('codex', env)
}, [onSave])

const status = useCodexDeviceCodeFlow({
onAuthenticated: handleAuthenticated,
})

if (status.state === 'error') {
return (
<Dialog title="Codex device-code sign-in failed" onCancel={onCancel} color="warning">
<Box flexDirection="column" gap={1}>
<Text>{status.message}</Text>
<Select
options={[
{ label: 'Back', value: 'back' },
{ label: 'Cancel', value: 'cancel' },
]}
onChange={(value: string) =>
value === 'back' ? onBack() : onCancel()
}
onCancel={onCancel}
/>
</Box>
</Dialog>
)
}

return (
<Dialog title="Codex Device Code" onCancel={onBack}>
<Box flexDirection="column" gap={1}>
{status.state === 'starting' ? (
<LoadingState message="Starting Codex device-code sign-in..." />
) : (
<>
<Text>
Visit {status.verificationUriComplete ?? status.verificationUri} and enter code{' '}
<Text bold>{status.userCode}</Text>.
</Text>
{status.browserOpened === false ? (
<Text color="warning">Browser did not open automatically. Visit the URL above.</Text>
) : status.browserOpened === true ? (
<Text dimColor>Verification page opened in your browser.</Text>
) : (
<Text dimColor>Opening your browser...</Text>
)}
<Text dimColor>
Expires in {Math.max(1, Math.ceil(status.expiresIn / 60))} minutes.
</Text>
</>
)}
<Text dimColor>Press Esc to cancel and go back.</Text>
</Box>
</Dialog>
)
}

function CodexOAuthStep({
onSave,
onBack,
Expand Down Expand Up @@ -1324,6 +1410,8 @@ export function ProviderWizard({
})
} else if (value === 'codex-oauth') {
setStep({ name: 'codex-oauth' })
} else if (value === 'codex-device-code') {
setStep({ name: 'codex-device-code' })
} else if (value === 'clear') {
const filePath = deleteProfileFile()
onDone(`Removed saved provider profile at ${filePath}. Restart OpenClaude to go back to normal startup.`, {
Expand Down Expand Up @@ -1778,6 +1866,15 @@ export function ProviderWizard({
/>
)

case 'codex-device-code':
return (
<CodexDeviceCodeStep
onSave={(profile, env) => finishProfileSave(onDone, profile, env)}
onBack={() => setStep({ name: 'choose' })}
onCancel={() => onDone()}
/>
)

case 'codex-oauth':
return (
<CodexOAuthStep
Expand Down
1 change: 1 addition & 0 deletions src/components/ProviderManager.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ const PRESET_ORDER = [
'Bankr',
'DeepSeek',
'Codex OAuth',
'Codex Device Code',
'Google Gemini',
'Groq',
'Hicap',
Expand Down
163 changes: 163 additions & 0 deletions src/components/ProviderManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import {
} from './CustomSelect/index.js'
import { Pane } from './design-system/Pane.js'
import TextInput from './TextInput.js'
import { useCodexDeviceCodeFlow } from './useCodexDeviceCodeFlow.js'
import { useCodexOAuthFlow } from './useCodexOAuthFlow.js'

export type ProviderManagerResult = {
Expand All @@ -94,6 +95,7 @@ type Screen =
| 'select-ollama-model'
| 'select-atomic-chat-model'
| 'codex-oauth'
| 'codex-device-code'
| 'form'
| 'select-active'
| 'select-edit'
Expand Down Expand Up @@ -199,6 +201,8 @@ const GITHUB_PROVIDER_DEFAULT_MODEL = 'github:copilot'
const GITHUB_PROVIDER_DEFAULT_BASE_URL = 'https://models.github.ai/inference'
const CODEX_OAUTH_PROVIDER_NAME = 'Codex OAuth'
const CODEX_OAUTH_PROVIDER_MODEL = 'codexplan'
const CODEX_DEVICE_CODE_PROVIDER_NAME = 'Codex Device Code'
const CODEX_DEVICE_CODE_PROVIDER_MODEL = 'codexplan'

type GithubCredentialSource = 'stored' | 'env' | 'none'

Expand Down Expand Up @@ -482,6 +486,88 @@ function CodexOAuthSetup({
)
}

function CodexDeviceCodeSetup({
onBack,
onConfigured,
}: {
onBack: () => void
onConfigured: (tokens: {
accessToken: string
refreshToken: string
accountId?: string
idToken?: string
apiKey?: string
}, persistCredentials: (options?: { profileId?: string }) => void) => void | Promise<void>
}): React.ReactNode {
const handleAuthenticated = React.useCallback(async (tokens: {
accessToken: string
refreshToken: string
accountId?: string
idToken?: string
apiKey?: string
}, persistCredentials: (options?: { profileId?: string }) => void) => {
await onConfigured(tokens, persistCredentials)
}, [onConfigured])
useKeybinding('confirm:no', onBack)

const status = useCodexDeviceCodeFlow({
onAuthenticated: handleAuthenticated,
})

if (status.state === 'error') {
return (
<Box flexDirection="column" gap={1}>
<Text color="error" bold>
Codex device-code sign-in failed
</Text>
<Text>{status.message}</Text>
<Text dimColor>Press Enter or Esc to go back.</Text>
<Select
options={[{ value: 'back', label: 'Back', description: 'Return to provider presets' }]}
onChange={onBack}
onCancel={onBack}
visibleOptionCount={1}
/>
</Box>
)
}

return (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>
Codex Device Code
</Text>
<Text>
Sign in with the device code shown below. OpenClaude will poll until the
login completes and then store the resulting Codex credentials securely.
</Text>
{status.state === 'starting' ? (
<Text dimColor>Requesting your device code...</Text>
) : (
<>
<Text>
Visit {status.verificationUriComplete ?? status.verificationUri} and enter code{' '}
<Text bold>{status.userCode}</Text>.
</Text>
<Text dimColor>
Expires in {Math.max(1, Math.ceil(status.expiresIn / 60))} minutes.
</Text>
{status.browserOpened === false ? (
<Text color="warning">Could not open the verification URL automatically.</Text>
) : status.browserOpened === true ? (
<Text dimColor>Verification page opened in your browser.</Text>
) : (
<Text dimColor>Opening the verification page...</Text>
)}
<Text>{status.verificationUriComplete ?? status.verificationUri}</Text>
</>
)}
<Text dimColor>Press Esc to cancel and go back.</Text>
</Box>
)
}


export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
const setAppState = useSetAppState()
const initialGithubCredentialSource = getGithubCredentialSourceFromEnv()
Expand Down Expand Up @@ -1456,6 +1542,12 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
})

if (canUseCodexOAuth) {
options.splice(7, 0, {
value: 'codex-device-code',
label: 'Codex Device Code',
description:
'Sign in with a device code and store Codex credentials securely',
})
options.splice(6, 0, {
value: 'codex-oauth',
label: (
Expand Down Expand Up @@ -1496,6 +1588,10 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
setScreen('codex-oauth')
return
}
if (value === 'codex-device-code') {
setScreen('codex-device-code')
return
}
startCreateFromPreset(value as ProviderPreset)
}}
onCancel={() => {
Expand Down Expand Up @@ -1783,6 +1879,73 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
case 'select-atomic-chat-model':
content = renderAtomicChatSelection()
break
case 'codex-device-code':
content = (
<CodexDeviceCodeSetup
onBack={() => setScreen('select-preset')}
onConfigured={async (tokens, persistCredentials) => {
const payload: ProviderProfileInput = {
provider: 'openai',
name: CODEX_DEVICE_CODE_PROVIDER_NAME,
baseUrl: DEFAULT_CODEX_BASE_URL,
model: CODEX_DEVICE_CODE_PROVIDER_MODEL,
apiKey: '',
}

const saved = addProviderProfile(payload, { makeActive: false })
if (!saved) {
setErrorMessage(
'Codex device-code login finished, but the provider profile could not be saved.',
)
returnToMenu()
return
}

const active = setActiveProviderProfile(saved.id)
if (!active) {
setErrorMessage(
'Codex device-code login finished, but the provider could not be set as the startup provider.',
)
returnToMenu()
return
}

persistCredentials({ profileId: saved.id })
const settingsOverrideError =
clearStartupProviderOverrideFromUserSettings()
const activationWarning = await activateCodexOAuthSession(tokens)
setHasStoredCodexOAuthCredentials(true)
setStoredCodexOAuthProfileId(saved.id)
refreshProfiles()
const warnings = [
activationWarning,
settingsOverrideError
? `could not clear startup provider override (${settingsOverrideError})`
: null,
].filter((warning): warning is string => Boolean(warning))
const message = buildCodexOAuthActivationMessage({
prefix: 'Codex Device Code configured',
activationWarning,
warnings,
})

if (mode === 'first-run') {
onDone({
action: 'saved',
activeProfileId: active.id,
message,
})
return
}

setStatusMessage(message)
setErrorMessage(undefined)
returnToMenu()
}}
/>
)
break

case 'codex-oauth':
content = (
<CodexOAuthSetup
Expand Down
Loading
Loading