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 { probeOllamaGenerationReadiness, type OllamaGenerationReadiness, } from '../utils/providerDiscovery.js' import { rankOllamaModels, recommendOllamaModel, } from '../utils/providerRecommendation.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' activeProfileId?: string message?: string } type Props = { mode: 'first-run' | 'manage' onDone: (result?: ProviderManagerResult) => void } type Screen = | 'menu' | 'select-preset' | 'select-ollama-model' | 'codex-oauth' | 'form' | 'select-active' | 'select-edit' | 'select-delete' type DraftField = 'name' | 'baseUrl' | 'model' | 'apiKey' type ProviderDraft = Record type OllamaSelectionState = | { 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 commas; first is default.', }, { 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 ?? '', } } function presetToDraft(preset: ProviderPreset): ProviderDraft { const defaults = getProviderPresetDefaults(preset) return { name: defaults.name, baseUrl: defaults.baseUrl, model: defaults.model, apiKey: defaults.apiKey ?? '', } } 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` return `${providerKind} · ${profile.baseUrl} · ${modelDisplay} · ${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 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 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 {FORM_STEPS.length}: {currentStep.label} {figures.pointer} setDraft(prev => ({ ...prev, [currentStepKey]: value, })) } onSubmit={handleFormSubmit} focus={true} showCursor={true} placeholder={`${currentStep.placeholder}${figures.ellipsis}`} mask={currentStepKey === 'apiKey' ? '*' : undefined} columns={80} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} /> {errorMessage && {errorMessage}} Press Enter to continue. Press Esc to go back. ) } function renderMenu(): React.ReactNode { // Use memoized menuOptions from component scope const hasProfiles = profiles.length > 0 const hasSelectableProviders = hasProfiles || githubProviderAvailable return ( Provider manager Active profile controls base URL, model, and API key used by this session. {statusMessage && {statusMessage}} {profiles.length === 0 && !githubProviderAvailable ? ( isGithubCredentialSourceResolved ? ( No provider profiles configured yet. ) : ( Checking GitHub Models credentials... ) ) : ( <> {profiles.map(profile => ( - {profile.name}: {profileSummary(profile, profile.id === activeProfileId)} ))} {githubProviderAvailable ? ( - {GITHUB_PROVIDER_LABEL}:{' '} {getGithubProviderSummary( isGithubActive, githubCredentialSource, )} ) : null} )} returnToMenu()} onCancel={() => returnToMenu()} visibleOptionCount={1} /> ) } return ( {title}