import figures from 'figures' import * as React from 'react' import { DEFAULT_CODEX_BASE_URL } from '../services/api/providerConfig.js' import { Box, Text } from '../ink.js' import { useKeybinding } from '../keybindings/useKeybinding.js' import { useSetAppState } from '../state/AppState.js' import type { ProviderProfile } from '../utils/config.js' import { clearCodexCredentials, readCodexCredentialsAsync, } from '../utils/codexCredentials.js' import { isBareMode, isEnvTruthy } from '../utils/envUtils.js' import { getPrimaryModel, hasMultipleModels, parseModelList } from '../utils/providerModels.js' import { applySavedProfileToCurrentSession, buildCodexOAuthProfileEnv, clearPersistedCodexOAuthProfile, createProfileFile, } from '../utils/providerProfile.js' import { addProviderProfile, applyActiveProviderProfileFromConfig, deleteProviderProfile, getActiveProviderProfile, getProviderPresetDefaults, getProviderProfiles, setActiveProviderProfile, type ProviderPreset, type ProviderProfileInput, updateProviderProfile, } from '../utils/providerProfiles.js' import { clearGithubModelsToken, GITHUB_MODELS_HYDRATED_ENV_MARKER, hydrateGithubModelsTokenFromSecureStorage, readGithubModelsToken, readGithubModelsTokenAsync, } from '../utils/githubModelsCredentials.js' import { probeAtomicChatReadiness, probeOllamaGenerationReadiness, type AtomicChatReadiness, type OllamaGenerationReadiness, } from '../utils/providerDiscovery.js' import { rankOllamaModels, recommendOllamaModel, } from '../utils/providerRecommendation.js' import { clearStartupProviderOverrides } from '../utils/providerStartupOverrides.js' import { redactUrlForDisplay } from '../utils/urlRedaction.js' import { updateSettingsForSource } from '../utils/settings/settings.js' import { type OptionWithDescription, Select, } from './CustomSelect/index.js' import { Pane } from './design-system/Pane.js' import TextInput from './TextInput.js' import { useCodexOAuthFlow } from './useCodexOAuthFlow.js' export type ProviderManagerResult = { action: 'saved' | 'cancelled' | 'activated' activeProfileId?: string activeProviderName?: string activeProviderModel?: string message?: string } type Props = { mode: 'first-run' | 'manage' onDone: (result?: ProviderManagerResult) => void } type Screen = | 'menu' | 'select-preset' | 'select-ollama-model' | 'select-atomic-chat-model' | 'codex-oauth' | 'form' | 'select-active' | 'select-edit' | 'select-delete' type DraftField = | 'name' | 'baseUrl' | 'model' | 'apiKey' | 'apiFormat' | 'authHeader' | 'authHeaderValue' type ProviderDraft = Record type OllamaSelectionState = | { state: 'idle' } | { state: 'loading' } | { state: 'ready' options: OptionWithDescription[] defaultValue?: string } | { state: 'unavailable'; message: string } type AtomicChatSelectionState = | { state: 'idle' } | { state: 'loading' } | { state: 'ready' options: OptionWithDescription[] defaultValue?: string } | { state: 'unavailable'; message: string } const FORM_STEPS: Array<{ key: DraftField label: string placeholder: string helpText: string optional?: boolean }> = [ { key: 'name', label: 'Provider name', placeholder: 'e.g. Ollama Home, OpenAI Work', helpText: 'A short label shown in /provider and startup setup.', }, { key: 'baseUrl', label: 'Base URL', placeholder: 'e.g. http://localhost:11434/v1', helpText: 'API base URL used for this provider profile.', }, { key: 'model', label: 'Default model', placeholder: 'e.g. llama3.1:8b or glm-4.7; glm-4.7-flash', helpText: 'Model name(s) to use. Separate multiple with ";" or ","; first is default.', }, { key: 'apiFormat', label: 'API mode', placeholder: 'chat_completions', helpText: 'Choose the OpenAI-compatible API surface for this provider.', optional: true, }, { key: 'authHeader', label: 'Auth header', placeholder: 'e.g. api-key or X-API-Key', helpText: 'Optional. Header name used for a custom provider key.', optional: true, }, { key: 'authHeaderValue', label: 'Auth header value', placeholder: 'Leave empty to use the API key value', helpText: 'Optional. Value sent in the custom auth header.', optional: true, }, { key: 'apiKey', label: 'API key', placeholder: 'Leave empty if your provider does not require one', helpText: 'Optional. Press Enter with empty value to skip.', optional: true, }, ] const GITHUB_PROVIDER_ID = '__github_models__' const GITHUB_PROVIDER_LABEL = 'GitHub Models' 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' type GithubCredentialSource = 'stored' | 'env' | 'none' function toDraft(profile: ProviderProfile): ProviderDraft { return { name: profile.name, baseUrl: profile.baseUrl, model: profile.model, apiKey: profile.apiKey ?? '', apiFormat: profile.apiFormat ?? 'chat_completions', authHeader: profile.authHeader ?? '', authHeaderValue: profile.authHeaderValue ?? '', } } function presetToDraft(preset: ProviderPreset): ProviderDraft { const defaults = getProviderPresetDefaults(preset) return { name: defaults.name, baseUrl: defaults.baseUrl, model: defaults.model, apiKey: defaults.apiKey ?? '', apiFormat: 'chat_completions', authHeader: '', authHeaderValue: '', } } function profileSummary(profile: ProviderProfile, isActive: boolean): string { const activeSuffix = isActive ? ' (active)' : '' const keyInfo = profile.apiKey ? 'key set' : 'no key' const providerKind = profile.provider === 'anthropic' ? 'anthropic' : 'openai-compatible' const models = parseModelList(profile.model) const modelDisplay = models.length <= 3 ? models.join(', ') : `${models[0]}, ${models[1]} + ${models.length - 2} more` const modeInfo = profile.provider === 'openai' ? ` · ${profile.apiFormat === 'responses' ? 'responses' : 'chat/completions'}` : '' const authInfo = profile.provider === 'openai' && profile.authHeader ? ` · ${profile.authHeader} auth` : '' return `${providerKind} · ${profile.baseUrl} · ${modelDisplay}${modeInfo}${authInfo} · ${keyInfo}${activeSuffix}` } function getGithubCredentialSourceFromEnv( processEnv: NodeJS.ProcessEnv = process.env, ): GithubCredentialSource { if (processEnv.GITHUB_TOKEN?.trim() || processEnv.GH_TOKEN?.trim()) { return 'env' } return 'none' } async function resolveGithubCredentialSource( processEnv: NodeJS.ProcessEnv = process.env, ): Promise { const envSource = getGithubCredentialSourceFromEnv(processEnv) if (envSource !== 'none') { return envSource } if (await readGithubModelsTokenAsync()) { return 'stored' } return 'none' } function isGithubProviderAvailable( credentialSource: GithubCredentialSource, processEnv: NodeJS.ProcessEnv = process.env, ): boolean { if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GITHUB)) { return true } return credentialSource !== 'none' } function getGithubProviderModel( processEnv: NodeJS.ProcessEnv = process.env, ): string { if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GITHUB)) { return processEnv.OPENAI_MODEL?.trim() || GITHUB_PROVIDER_DEFAULT_MODEL } return GITHUB_PROVIDER_DEFAULT_MODEL } function getGithubProviderSummary( isActive: boolean, credentialSource: GithubCredentialSource, processEnv: NodeJS.ProcessEnv = process.env, ): string { const credentialSummary = credentialSource === 'stored' ? 'token stored' : credentialSource === 'env' ? 'token via env' : 'no token found' const activeSuffix = isActive ? ' (active)' : '' return `github-models · ${GITHUB_PROVIDER_DEFAULT_BASE_URL} · ${getGithubProviderModel(processEnv)} · ${credentialSummary}${activeSuffix}` } function describeAtomicChatSelectionIssue( readiness: AtomicChatReadiness, baseUrl: string, ): string { if (readiness.state === 'unreachable') { return `Could not reach Atomic Chat at ${redactUrlForDisplay(baseUrl)}. Start the Atomic Chat app first, or enter the endpoint manually.` } if (readiness.state === 'no_models') { return 'Atomic Chat is running, but no models are loaded. Download and load a model inside the Atomic Chat app first, or enter details manually.' } return '' } function describeOllamaSelectionIssue( readiness: OllamaGenerationReadiness, baseUrl: string, ): string { if (readiness.state === 'unreachable') { return `Could not reach Ollama at ${redactUrlForDisplay(baseUrl)}. Start Ollama first, or enter the endpoint manually.` } if (readiness.state === 'no_models') { return 'Ollama is running, but no installed models were found. Pull a chat model such as qwen2.5-coder:7b or llama3.1:8b first, or enter details manually.' } if (readiness.state === 'generation_failed') { const modelHint = readiness.probeModel ?? 'the selected model' const detailSuffix = readiness.detail ? ` Details: ${readiness.detail}.` : '' return `Ollama is reachable and models are installed, but a generation probe failed for ${modelHint}.${detailSuffix} Run "ollama run ${modelHint}" once and retry, or enter details manually.` } return '' } function findCodexOAuthProfile( profiles: ProviderProfile[], profileId?: string, ): ProviderProfile | undefined { if (!profileId) { return undefined } return profiles.find(profile => profile.id === profileId) } function isCodexOAuthProfile( profile: ProviderProfile | null | undefined, profileId?: string, ): boolean { return Boolean(profile && profileId && profile.id === profileId) } function CodexOAuthSetup({ onBack, onConfigured, }: { onBack: () => void onConfigured: (tokens: { accessToken: string refreshToken: string accountId?: string idToken?: string apiKey?: string }, persistCredentials: (options?: { profileId?: string }) => void) => void | Promise }): 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, [onBack]) const status = useCodexOAuthFlow({ onAuthenticated: handleAuthenticated, }) if (status.state === 'error') { return ( Codex OAuth failed {status.message} Press Enter or Esc to go back. { if (value === 'manual') { setFormStepIndex(0) setCursorOffset(draft.name.length) setScreen('form') return } setScreen('select-preset') }} onCancel={() => setScreen('select-preset')} visibleOptionCount={2} /> ) } return ( Choose an Atomic Chat model Pick one of the models loaded in Atomic Chat to save into a local provider profile. { if (value === 'manual') { setFormStepIndex(0) setCursorOffset(draft.name.length) setScreen('form') return } setScreen('select-preset') }} onCancel={() => setScreen('select-preset')} visibleOptionCount={2} /> ) } return ( Choose an Ollama model Pick one of the installed Ollama models to save into a local provider profile. { if (value === 'skip') { closeWithCancelled('Provider setup skipped') return } if (value === 'codex-oauth') { setScreen('codex-oauth') return } startCreateFromPreset(value as ProviderPreset) }} onCancel={() => { if (mode === 'first-run') { closeWithCancelled('Provider setup skipped') return } returnToMenu() }} visibleOptionCount={Math.min(13, options.length)} /> ) } function renderForm(): React.ReactNode { return ( {editingProfileId ? 'Edit provider profile' : 'Create provider profile'} {currentStep.helpText} Provider type:{' '} {draftProvider === 'anthropic' ? 'Anthropic native API' : 'OpenAI-compatible API'} Step {formStepIndex + 1} of {formSteps.length}: {currentStep.label} {currentStepKey === 'apiFormat' ? ( { setErrorMessage(undefined) switch (value) { case 'add': setScreen('select-preset') break case 'activate': if (hasSelectableProviders) { setScreen('select-active') } break case 'edit': if (hasProfiles) { setScreen('select-edit') } break case 'delete': if (hasSelectableProviders) { setScreen('select-delete') } break case 'logout-codex-oauth': { const cleared = clearCodexCredentials() if (!cleared.success) { setErrorMessage( cleared.warning ?? 'Could not clear Codex OAuth credentials.', ) break } setHasStoredCodexOAuthCredentials(false) setStoredCodexOAuthProfileId(undefined) const codexProfile = findCodexOAuthProfile( getProviderProfiles(), storedCodexOAuthProfileId, ) let settingsOverrideError: string | null = null if (codexProfile) { const result = deleteProviderProfile(codexProfile.id) if (!result.removed) { setErrorMessage( 'Codex OAuth credentials were cleared, but the Codex profile could not be removed.', ) refreshProfiles() break } clearPersistedCodexOAuthProfile() settingsOverrideError = result.activeProfileId ? clearStartupProviderOverrideFromUserSettings() : null } refreshProfiles() setStatusMessage( settingsOverrideError ? `Codex OAuth logged out. Warning: could not clear startup provider override (${settingsOverrideError}).` : 'Codex OAuth logged out.', ) break } default: closeWithCancelled('Provider manager closed') break } }} onCancel={() => closeWithCancelled('Provider manager closed')} defaultFocusValue={menuFocusValue} visibleOptionCount={menuOptions.length} /> ) } function renderProfileSelection( title: string, emptyMessage: string, onSelect: (profileId: string) => void, options?: { includeGithub?: boolean }, ): React.ReactNode { const includeGithub = options?.includeGithub ?? false const selectOptions = profiles.map(profile => ({ value: profile.id, label: profile.id === activeProfileId ? `${profile.name} (active)` : profile.name, description: `${profile.provider === 'anthropic' ? 'anthropic' : 'openai-compatible'} · ${profile.baseUrl} · ${profile.model}`, })) if (includeGithub && githubProviderAvailable) { selectOptions.push({ value: GITHUB_PROVIDER_ID, label: isGithubActive ? `${GITHUB_PROVIDER_LABEL} (active)` : GITHUB_PROVIDER_LABEL, description: `github-models · ${GITHUB_PROVIDER_DEFAULT_BASE_URL} · ${getGithubProviderModel()}`, }) } if (selectOptions.length === 0) { return ( {title} {emptyMessage} returnToMenu()} visibleOptionCount={Math.min(10, Math.max(2, selectOptions.length))} /> ) } let content: React.ReactNode switch (screen) { case 'select-preset': content = renderPresetSelection() break case 'select-ollama-model': content = renderOllamaSelection() break case 'select-atomic-chat-model': content = renderAtomicChatSelection() break case 'codex-oauth': content = ( setScreen('select-preset')} onConfigured={async (tokens, persistCredentials) => { const payload: ProviderProfileInput = { provider: 'openai', name: CODEX_OAUTH_PROVIDER_NAME, baseUrl: DEFAULT_CODEX_BASE_URL, model: CODEX_OAUTH_PROVIDER_MODEL, apiKey: '', } const existing = findCodexOAuthProfile( getProviderProfiles(), storedCodexOAuthProfileId, ) const saved = existing ? updateProviderProfile(existing.id, payload) : addProviderProfile(payload, { makeActive: true }) if (!saved) { setErrorMessage( 'Codex OAuth login finished, but the provider profile could not be saved.', ) returnToMenu() return } const active = existing && activeProfileId !== saved.id ? setActiveProviderProfile(saved.id) : saved if (!active) { setErrorMessage( 'Codex OAuth 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 OAuth configured', activationWarning, warnings, }) if (mode === 'first-run') { onDone({ action: 'saved', activeProfileId: active.id, message, }) return } setStatusMessage(message) setErrorMessage(undefined) returnToMenu() }} /> ) break case 'form': content = renderForm() break case 'select-active': content = renderProfileSelection( 'Set active provider', 'No providers available. Add one first.', profileId => { void activateSelectedProvider(profileId) }, { includeGithub: true }, ) break case 'select-edit': content = renderProfileSelection( 'Edit provider', 'No providers available. Add one first.', profileId => { startEditProfile(profileId) }, ) break case 'select-delete': content = renderProfileSelection( 'Delete provider', 'No providers available. Add one first.', profileId => { if (profileId === GITHUB_PROVIDER_ID) { const githubDeleteError = deleteGithubProvider() if (githubDeleteError) { setErrorMessage(`Could not delete GitHub provider: ${githubDeleteError}`) } else { refreshProfiles() setStatusMessage('GitHub provider deleted') } returnToMenu() return } const deletedCodexOAuthProfile = findCodexOAuthProfile( profiles, storedCodexOAuthProfileId, )?.id === profileId const result = deleteProviderProfile(profileId) if (!result.removed) { setErrorMessage('Could not delete provider.') } else { if (deletedCodexOAuthProfile) { const cleared = clearCodexCredentials() if (!cleared.success) { setErrorMessage( cleared.warning ?? 'Provider deleted, but Codex OAuth credentials could not be cleared.', ) } else { setStoredCodexOAuthProfileId(undefined) } clearPersistedCodexOAuthProfile() } const settingsOverrideError = result.activeProfileId ? clearStartupProviderOverrideFromUserSettings() : null refreshProfiles() setStatusMessage( settingsOverrideError ? `Provider deleted. Warning: could not clear startup provider override (${settingsOverrideError}).` : 'Provider deleted', ) } returnToMenu() }, { includeGithub: true }, ) break case 'menu': default: content = renderMenu() break } return ( {isInitializing ? ( Loading providers... Reading provider profiles from disk. ) : isActivating ? ( Activating provider... Please wait while the provider is being configured. ) : ( content )} ) }