diff --git a/src/components/ConsoleOAuthFlow.tsx b/src/components/ConsoleOAuthFlow.tsx index b71ab02e5..b89f88056 100644 --- a/src/components/ConsoleOAuthFlow.tsx +++ b/src/components/ConsoleOAuthFlow.tsx @@ -1,52 +1,52 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js'; -import { installOAuthTokens } from '../cli/handlers/auth.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { setClipboard } from '../ink/termio/osc.js'; -import { useTerminalNotification } from '../ink/useTerminalNotification.js'; -import { Box, Link, Text } from '../ink.js'; -import { useKeybinding } from '../keybindings/useKeybinding.js'; -import { getSSLErrorHint } from '../services/api/errorUtils.js'; -import { sendNotification } from '../services/notifier.js'; -import { OAuthService } from '../services/oauth/index.js'; -import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js'; -import { logError } from '../utils/log.js'; -import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js'; -import { Select } from './CustomSelect/select.js'; -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; -import { Spinner } from './Spinner.js'; -import TextInput from './TextInput.js'; +} from 'src/services/analytics/index.js' +import { installOAuthTokens } from '../cli/handlers/auth.js' +import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { setClipboard } from '../ink/termio/osc.js' +import { useTerminalNotification } from '../ink/useTerminalNotification.js' +import { Box, Link, Text } from '../ink.js' +import { useKeybinding } from '../keybindings/useKeybinding.js' +import { getSSLErrorHint } from '../services/api/errorUtils.js' +import { sendNotification } from '../services/notifier.js' +import { OAuthService } from '../services/oauth/index.js' +import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js' +import { logError } from '../utils/log.js' +import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js' +import { Select } from './CustomSelect/select.js' +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import { Spinner } from './Spinner.js' +import TextInput from './TextInput.js' type Props = { - onDone(): void; - startingMessage?: string; - mode?: 'login' | 'setup-token'; - forceLoginMethod?: 'claudeai' | 'console'; -}; + onDone(): void + startingMessage?: string + mode?: 'login' | 'setup-token' + forceLoginMethod?: 'claudeai' | 'console' +} type OAuthStatus = | { state: 'idle' } // Initial state, waiting to select login method | { state: 'platform_setup' } // Show platform setup info (Bedrock/Vertex/Foundry) | { - state: 'custom_platform'; - baseUrl: string; - apiKey: string; - haikuModel: string; - sonnetModel: string; - opusModel: string; - activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; + state: 'custom_platform' + baseUrl: string + apiKey: string + haikuModel: string + sonnetModel: string + opusModel: string + activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' } // Custom platform: configure API endpoint and model names | { - state: 'openai_chat_api'; - baseUrl: string; - apiKey: string; - haikuModel: string; - sonnetModel: string; - opusModel: string; - activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; + state: 'openai_chat_api' + baseUrl: string + apiKey: string + haikuModel: string + sonnetModel: string + opusModel: string + activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' } // OpenAI Chat Completions API platform | { state: 'ready_to_start' } // Flow started, waiting for browser to open | { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login @@ -54,12 +54,12 @@ type OAuthStatus = | { state: 'about_to_retry'; nextState: OAuthStatus } | { state: 'success'; token?: string } | { - state: 'error'; - message: string; - toRetry?: OAuthStatus; - }; + state: 'error' + message: string + toRetry?: OAuthStatus + } -const PASTE_HERE_MSG = 'Paste code here if prompted > '; +const PASTE_HERE_MSG = 'Paste code here if prompted > ' export function ConsoleOAuthFlow({ onDone, @@ -67,153 +67,158 @@ export function ConsoleOAuthFlow({ mode = 'login', forceLoginMethod: forceLoginMethodProp, }: Props): React.ReactNode { - const settings = getSettings_DEPRECATED() || {}; - const forceLoginMethod = forceLoginMethodProp ?? settings.forceLoginMethod; - const orgUUID = settings.forceLoginOrgUUID; + const settings = getSettings_DEPRECATED() || {} + const forceLoginMethod = forceLoginMethodProp ?? settings.forceLoginMethod + const orgUUID = settings.forceLoginOrgUUID const forcedMethodMessage = forceLoginMethod === 'claudeai' ? 'Login method pre-selected: Subscription Plan (Claude Pro/Max)' : forceLoginMethod === 'console' ? 'Login method pre-selected: API Usage Billing (Anthropic Console)' - : null; + : null - const terminal = useTerminalNotification(); + const terminal = useTerminalNotification() const [oauthStatus, setOAuthStatus] = useState(() => { if (mode === 'setup-token') { - return { state: 'ready_to_start' }; + return { state: 'ready_to_start' } } if (forceLoginMethod === 'claudeai' || forceLoginMethod === 'console') { - return { state: 'ready_to_start' }; + return { state: 'ready_to_start' } } - return { state: 'idle' }; - }); + return { state: 'idle' } + }) - const [pastedCode, setPastedCode] = useState(''); - const [cursorOffset, setCursorOffset] = useState(0); - const [oauthService] = useState(() => new OAuthService()); + const [pastedCode, setPastedCode] = useState('') + const [cursorOffset, setCursorOffset] = useState(0) + const [oauthService] = useState(() => new OAuthService()) const [loginWithClaudeAi, setLoginWithClaudeAi] = useState(() => { // Use Claude AI auth for setup-token mode to support user:inference scope - return mode === 'setup-token' || forceLoginMethod === 'claudeai'; - }); + return mode === 'setup-token' || forceLoginMethod === 'claudeai' + }) // After a few seconds we suggest the user to copy/paste url if the // browser did not open automatically. In this flow we expect the user to // copy the code from the browser and paste it in the terminal - const [showPastePrompt, setShowPastePrompt] = useState(false); - const [urlCopied, setUrlCopied] = useState(false); + const [showPastePrompt, setShowPastePrompt] = useState(false) + const [urlCopied, setUrlCopied] = useState(false) - const textInputColumns = useTerminalSize().columns - PASTE_HERE_MSG.length - 1; + const textInputColumns = useTerminalSize().columns - PASTE_HERE_MSG.length - 1 // Log forced login method on mount useEffect(() => { if (forceLoginMethod === 'claudeai') { - logEvent('tengu_oauth_claudeai_forced', {}); + logEvent('tengu_oauth_claudeai_forced', {}) } else if (forceLoginMethod === 'console') { - logEvent('tengu_oauth_console_forced', {}); + logEvent('tengu_oauth_console_forced', {}) } - }, [forceLoginMethod]); + }, [forceLoginMethod]) // Retry logic useEffect(() => { if (oauthStatus.state === 'about_to_retry') { - const timer = setTimeout(setOAuthStatus, 1000, oauthStatus.nextState); - return () => clearTimeout(timer); + const timer = setTimeout(setOAuthStatus, 1000, oauthStatus.nextState) + return () => clearTimeout(timer) } - }, [oauthStatus]); + }, [oauthStatus.state, oauthStatus.nextState]) // Handle Enter to continue on success state useKeybinding( 'confirm:yes', () => { - logEvent('tengu_oauth_success', { loginWithClaudeAi }); - onDone(); + logEvent('tengu_oauth_success', { loginWithClaudeAi }) + onDone() }, { context: 'Confirmation', isActive: oauthStatus.state === 'success' && mode !== 'setup-token', }, - ); + ) // Handle Enter to continue from platform setup useKeybinding( 'confirm:yes', () => { - setOAuthStatus({ state: 'idle' }); + setOAuthStatus({ state: 'idle' }) }, { context: 'Confirmation', isActive: oauthStatus.state === 'platform_setup', }, - ); + ) // Handle Enter to retry on error state useKeybinding( 'confirm:yes', () => { if (oauthStatus.state === 'error' && oauthStatus.toRetry) { - setPastedCode(''); + setPastedCode('') setOAuthStatus({ state: 'about_to_retry', nextState: oauthStatus.toRetry, - }); + }) } }, { context: 'Confirmation', isActive: oauthStatus.state === 'error' && !!oauthStatus.toRetry, }, - ); + ) useEffect(() => { - if (pastedCode === 'c' && oauthStatus.state === 'waiting_for_login' && showPastePrompt && !urlCopied) { + if ( + pastedCode === 'c' && + oauthStatus.state === 'waiting_for_login' && + showPastePrompt && + !urlCopied + ) { void setClipboard(oauthStatus.url).then(raw => { - if (raw) process.stdout.write(raw); - setUrlCopied(true); - setTimeout(setUrlCopied, 2000, false); - }); - setPastedCode(''); + if (raw) process.stdout.write(raw) + setUrlCopied(true) + setTimeout(setUrlCopied, 2000, false) + }) + setPastedCode('') } - }, [pastedCode, oauthStatus, showPastePrompt, urlCopied]); + }, [pastedCode, oauthStatus.state, oauthStatus.url, showPastePrompt, urlCopied]) async function handleSubmitCode(value: string, url: string) { try { // Expecting format "authorizationCode#state" from the authorization callback URL - const [authorizationCode, state] = value.split('#'); + const [authorizationCode, state] = value.split('#') if (!authorizationCode || !state) { setOAuthStatus({ state: 'error', message: 'Invalid code. Please make sure the full code was copied', toRetry: { state: 'waiting_for_login', url }, - }); - return; + }) + return } // Track which path the user is taking (manual code entry) - logEvent('tengu_oauth_manual_entry', {}); + logEvent('tengu_oauth_manual_entry', {}) oauthService.handleManualAuthCodeInput({ authorizationCode, state, - }); + }) } catch (err: unknown) { - logError(err); + logError(err) setOAuthStatus({ state: 'error', message: (err as Error).message, toRetry: { state: 'waiting_for_login', url }, - }); + }) } } const startOAuth = useCallback(async () => { try { - logEvent('tengu_oauth_flow_start', { loginWithClaudeAi }); + logEvent('tengu_oauth_flow_start', { loginWithClaudeAi }) const result = await oauthService .startOAuthFlow( async url => { - setOAuthStatus({ state: 'waiting_for_login', url }); - setTimeout(setShowPastePrompt, 3000, true); + setOAuthStatus({ state: 'waiting_for_login', url }) + setTimeout(setShowPastePrompt, 3000, true) }, { loginWithClaudeAi, @@ -223,11 +228,13 @@ export function ConsoleOAuthFlow({ }, ) .catch(err => { - const isTokenExchangeError = err.message.includes('Token exchange failed'); + const isTokenExchangeError = err.message.includes( + 'Token exchange failed', + ) // Enterprise TLS proxies (Zscaler et al.) intercept the token // exchange POST and cause cryptic SSL errors. Surface an // actionable hint so the user isn't stuck in a login loop. - const sslHint = getSSLErrorHint(err); + const sslHint = getSSLErrorHint(err) setOAuthStatus({ state: 'error', message: @@ -235,70 +242,73 @@ export function ConsoleOAuthFlow({ (isTokenExchangeError ? 'Failed to exchange authorization code for access token. Please try again.' : err.message), - toRetry: mode === 'setup-token' ? { state: 'ready_to_start' } : { state: 'idle' }, - }); + toRetry: + mode === 'setup-token' + ? { state: 'ready_to_start' } + : { state: 'idle' }, + }) logEvent('tengu_oauth_token_exchange_error', { error: err.message, ssl_error: sslHint !== null, - }); - throw err; - }); + }) + throw err + }) if (mode === 'setup-token') { // For setup-token mode, return the OAuth access token directly (it can be used as an API key) // Don't save to keychain - the token is displayed for manual use with CLAUDE_CODE_OAUTH_TOKEN - setOAuthStatus({ state: 'success', token: result.accessToken }); + setOAuthStatus({ state: 'success', token: result.accessToken }) } else { - await installOAuthTokens(result); + await installOAuthTokens(result) - const orgResult = await validateForceLoginOrg(); + const orgResult = await validateForceLoginOrg() if (!orgResult.valid) { - throw new Error(orgResult.message); + throw new Error(orgResult.message) } // Reset modelType to anthropic when using OAuth login - updateSettingsForSource('userSettings', { modelType: 'anthropic' } as any); + updateSettingsForSource('userSettings', { modelType: 'anthropic' } as any) - setOAuthStatus({ state: 'success' }); + setOAuthStatus({ state: 'success' }) void sendNotification( { message: 'Claude Code login successful', notificationType: 'auth_success', }, terminal, - ); + ) } } catch (err) { - const errorMessage = (err as Error).message; - const sslHint = getSSLErrorHint(err); + const errorMessage = (err as Error).message + const sslHint = getSSLErrorHint(err) setOAuthStatus({ state: 'error', message: sslHint ?? errorMessage, toRetry: { state: mode === 'setup-token' ? 'ready_to_start' : 'idle', }, - }); + }) logEvent('tengu_oauth_error', { - error: errorMessage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + error: + errorMessage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ssl_error: sslHint !== null, - }); + }) } - }, [oauthService, setShowPastePrompt, loginWithClaudeAi, mode, orgUUID]); + }, [oauthService, setShowPastePrompt, loginWithClaudeAi, mode, orgUUID]) - const pendingOAuthStartRef = useRef(false); + const pendingOAuthStartRef = useRef(false) useEffect(() => { - if (oauthStatus.state === 'ready_to_start' && !pendingOAuthStartRef.current) { - pendingOAuthStartRef.current = true; - process.nextTick( - (startOAuth: () => Promise, pendingOAuthStartRef: React.MutableRefObject) => { - void startOAuth(); - pendingOAuthStartRef.current = false; - }, - startOAuth, - pendingOAuthStartRef, - ); + if ( + oauthStatus.state === 'ready_to_start' && + !pendingOAuthStartRef.current + ) { + pendingOAuthStartRef.current = true + // Start OAuth flow and reset the pending flag when complete + void startOAuth().finally(() => { + pendingOAuthStartRef.current = false + }) } - }, [oauthStatus.state, startOAuth]); + }, [oauthStatus.state, startOAuth]) // Auto-exit for setup-token mode useEffect(() => { @@ -306,31 +316,33 @@ export function ConsoleOAuthFlow({ // Delay to ensure static content is fully rendered before exiting const timer = setTimeout( (loginWithClaudeAi, onDone) => { - logEvent('tengu_oauth_success', { loginWithClaudeAi }); + logEvent('tengu_oauth_success', { loginWithClaudeAi }) // Don't clear terminal so the token remains visible - onDone(); + onDone() }, 500, loginWithClaudeAi, onDone, - ); - return () => clearTimeout(timer); + ) + return () => clearTimeout(timer) } - }, [mode, oauthStatus, loginWithClaudeAi, onDone]); + }, [mode, oauthStatus, loginWithClaudeAi, onDone]) // Cleanup OAuth service when component unmounts useEffect(() => { return () => { - oauthService.cleanup(); - }; - }, [oauthService]); + oauthService.cleanup() + } + }, [oauthService]) return ( {oauthStatus.state === 'waiting_for_login' && showPastePrompt && ( - Browser didn't open? Use the url below to sign in + + Browser didn't open? Use the url below to sign in{' '} + {urlCopied ? ( (Copied!) ) : ( @@ -344,17 +356,27 @@ export function ConsoleOAuthFlow({ )} - {mode === 'setup-token' && oauthStatus.state === 'success' && oauthStatus.token && ( - - ✓ Long-lived authentication token created successfully! - - Your OAuth token (valid for 1 year): - {oauthStatus.token} - Store this token securely. You won't be able to see it again. - Use this token by setting: export CLAUDE_CODE_OAUTH_TOKEN=<token> + {mode === 'setup-token' && + oauthStatus.state === 'success' && + oauthStatus.token && ( + + + ✓ Long-lived authentication token created successfully! + + + Your OAuth token (valid for 1 year): + {oauthStatus.token} + + Store this token securely. You won't be able to see it + again. + + + Use this token by setting: export + CLAUDE_CODE_OAUTH_TOKEN=<token> + + - - )} + )} - ); + ) } type OAuthStatusMessageProps = { - oauthStatus: OAuthStatus; - mode: 'login' | 'setup-token'; - startingMessage: string | undefined; - forcedMethodMessage: string | null; - showPastePrompt: boolean; - pastedCode: string; - setPastedCode: (value: string) => void; - cursorOffset: number; - onDone: () => void; - setCursorOffset: (offset: number) => void; - textInputColumns: number; - handleSubmitCode: (value: string, url: string) => void; - setOAuthStatus: (status: OAuthStatus) => void; - setLoginWithClaudeAi: (value: boolean) => void; -}; + oauthStatus: OAuthStatus + mode: 'login' | 'setup-token' + startingMessage: string | undefined + forcedMethodMessage: string | null + showPastePrompt: boolean + pastedCode: string + setPastedCode: (value: string) => void + cursorOffset: number + onDone: () => void + setCursorOffset: (offset: number) => void + textInputColumns: number + handleSubmitCode: (value: string, url: string) => void + setOAuthStatus: (status: OAuthStatus) => void + setLoginWithClaudeAi: (value: boolean) => void +} function OAuthStatusMessage({ oauthStatus, @@ -428,7 +450,8 @@ function OAuthStatusMessage({ { label: ( - Anthropic Compatible · Configure your own API endpoint + Anthropic Compatible ·{' '} + Configure your own API endpoint {'\n'} ), @@ -437,7 +460,10 @@ function OAuthStatusMessage({ { label: ( - OpenAI Compatible · Ollama, DeepSeek, vLLM, One API, etc. + OpenAI Compatible ·{' '} + + Ollama, DeepSeek, vLLM, One API, etc. + {'\n'} ), @@ -446,14 +472,16 @@ function OAuthStatusMessage({ { label: ( - Claude account with subscription · Pro, Max, Team, or Enterprise + Claude account with subscription ·{' '} + Pro, Max, Team, or Enterprise {process.env.USER_TYPE === 'ant' && ( {'\n'} [ANT-ONLY]{' '} - Please use this option unless you need to login to a special org for accessing sensitive - data (e.g. customer data, HIPI data) with the Console option + Please use this option unless you need to login to a + special org for accessing sensitive data (e.g. + customer data, HIPI data) with the Console option )} @@ -465,7 +493,8 @@ function OAuthStatusMessage({ { label: ( - Anthropic Console account · API usage billing + Anthropic Console account ·{' '} + API usage billing {'\n'} ), @@ -474,7 +503,10 @@ function OAuthStatusMessage({ { label: ( - 3rd-party platform · Amazon Bedrock, Microsoft Foundry, or Vertex AI + 3rd-party platform ·{' '} + + Amazon Bedrock, Microsoft Foundry, or Vertex AI + {'\n'} ), @@ -483,7 +515,7 @@ function OAuthStatusMessage({ ]} onChange={value => { if (value === 'custom_platform') { - logEvent('tengu_custom_platform_selected', {}); + logEvent('tengu_custom_platform_selected', {}) setOAuthStatus({ state: 'custom_platform', baseUrl: process.env.ANTHROPIC_BASE_URL ?? '', @@ -492,9 +524,9 @@ function OAuthStatusMessage({ sonnetModel: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? '', opusModel: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL ?? '', activeField: 'base_url', - }); + }) } else if (value === 'openai_chat_api') { - logEvent('tengu_openai_chat_api_selected', {}); + logEvent('tengu_openai_chat_api_selected', {}) setOAuthStatus({ state: 'openai_chat_api', baseUrl: process.env.OPENAI_BASE_URL ?? '', @@ -503,385 +535,439 @@ function OAuthStatusMessage({ sonnetModel: process.env.OPENAI_DEFAULT_SONNET_MODEL ?? '', opusModel: process.env.OPENAI_DEFAULT_OPUS_MODEL ?? '', activeField: 'base_url', - }); + }) } else if (value === 'platform') { - logEvent('tengu_oauth_platform_selected', {}); - setOAuthStatus({ state: 'platform_setup' }); + logEvent('tengu_oauth_platform_selected', {}) + setOAuthStatus({ state: 'platform_setup' }) } else { - setOAuthStatus({ state: 'ready_to_start' }); + setOAuthStatus({ state: 'ready_to_start' }) if (value === 'claudeai') { - logEvent('tengu_oauth_claudeai_selected', {}); - setLoginWithClaudeAi(true); + logEvent('tengu_oauth_claudeai_selected', {}) + setLoginWithClaudeAi(true) } else { - logEvent('tengu_oauth_console_selected', {}); - setLoginWithClaudeAi(false); + logEvent('tengu_oauth_console_selected', {}) + setLoginWithClaudeAi(false) } } }} /> - ); + ) - case 'custom_platform': { - type Field = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; - const FIELDS: Field[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model']; - const cp = oauthStatus as { - state: 'custom_platform'; - activeField: Field; - baseUrl: string; - apiKey: string; - haikuModel: string; - sonnetModel: string; - opusModel: string; - }; - const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = cp; - const displayValues: Record = { - base_url: baseUrl, - api_key: apiKey, - haiku_model: haikuModel, - sonnet_model: sonnetModel, - opus_model: opusModel, - }; - - const [inputValue, setInputValue] = useState(() => displayValues[activeField]); - const [inputCursorOffset, setInputCursorOffset] = useState(() => displayValues[activeField].length); - - const buildState = useCallback( - (field: Field, value: string, newActive?: Field) => { - const s = { - state: 'custom_platform' as const, - activeField: newActive ?? activeField, - baseUrl, - apiKey, - haikuModel, - sonnetModel, - opusModel, - }; - switch (field) { - case 'base_url': - return { ...s, baseUrl: value }; - case 'api_key': - return { ...s, apiKey: value }; - case 'haiku_model': - return { ...s, haikuModel: value }; - case 'sonnet_model': - return { ...s, sonnetModel: value }; - case 'opus_model': - return { ...s, opusModel: value }; - } - }, - [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel], - ); - - const switchTo = useCallback( - (target: Field) => { - setOAuthStatus(buildState(activeField, inputValue, target)); - setInputValue(displayValues[target] ?? ''); - setInputCursorOffset((displayValues[target] ?? '').length); - }, - [activeField, inputValue, displayValues, buildState, setOAuthStatus], - ); - - const doSave = useCallback(() => { - const finalVals = { ...displayValues, [activeField]: inputValue }; - const env: Record = {}; - if (finalVals.base_url) env.ANTHROPIC_BASE_URL = finalVals.base_url; - if (finalVals.api_key) env.ANTHROPIC_AUTH_TOKEN = finalVals.api_key; - if (finalVals.haiku_model) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = finalVals.haiku_model; - if (finalVals.sonnet_model) env.ANTHROPIC_DEFAULT_SONNET_MODEL = finalVals.sonnet_model; - if (finalVals.opus_model) env.ANTHROPIC_DEFAULT_OPUS_MODEL = finalVals.opus_model; - const { error } = updateSettingsForSource('userSettings', { - modelType: 'anthropic' as any, - env, - } as any); - if (error) { - setOAuthStatus({ - state: 'error', - message: `Failed to save: ${error.message}`, - toRetry: { - state: 'custom_platform', - baseUrl: '', - apiKey: '', - haikuModel: '', - sonnetModel: '', - opusModel: '', - activeField: 'base_url', - }, - }); - } else { - for (const [k, v] of Object.entries(env)) process.env[k] = v; - setOAuthStatus({ state: 'success' }); - void onDone(); + case 'custom_platform': + { + type Field = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' + const FIELDS: Field[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model'] + const cp = oauthStatus as { + state: 'custom_platform' + activeField: Field + baseUrl: string + apiKey: string + haikuModel: string + sonnetModel: string + opusModel: string } - }, [activeField, inputValue, displayValues, setOAuthStatus, onDone]); - - const handleEnter = useCallback(() => { - const idx = FIELDS.indexOf(activeField); - setOAuthStatus(buildState(activeField, inputValue)); - if (idx === FIELDS.length - 1) { - doSave(); - } else { - const next = FIELDS[idx + 1]!; - setInputValue(displayValues[next] ?? ''); - setInputCursorOffset((displayValues[next] ?? '').length); + const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = cp + const displayValues: Record = { + base_url: baseUrl, + api_key: apiKey, + haiku_model: haikuModel, + sonnet_model: sonnetModel, + opus_model: opusModel, } - }, [activeField, inputValue, buildState, doSave, displayValues, setOAuthStatus]); - useKeybinding( - 'tabs:next', - () => { - const idx = FIELDS.indexOf(activeField); - if (idx < FIELDS.length - 1) { - setOAuthStatus(buildState(activeField, inputValue, FIELDS[idx + 1])); - setInputValue(displayValues[FIELDS[idx + 1]!] ?? ''); - setInputCursorOffset((displayValues[FIELDS[idx + 1]!] ?? '').length); + const [inputValue, setInputValue] = useState(() => displayValues[activeField]) + const [inputCursorOffset, setInputCursorOffset] = useState( + () => displayValues[activeField].length, + ) + + const buildState = useCallback( + (field: Field, value: string, newActive?: Field) => { + const s = { + state: 'custom_platform' as const, + activeField: newActive ?? activeField, + baseUrl, + apiKey, + haikuModel, + sonnetModel, + opusModel, + } + switch (field) { + case 'base_url': + return { ...s, baseUrl: value } + case 'api_key': + return { ...s, apiKey: value } + case 'haiku_model': + return { ...s, haikuModel: value } + case 'sonnet_model': + return { ...s, sonnetModel: value } + case 'opus_model': + return { ...s, opusModel: value } + } + }, + [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel], + ) + + const switchTo = useCallback( + (target: Field) => { + setOAuthStatus(buildState(activeField, inputValue, target)) + setInputValue(displayValues[target] ?? '') + setInputCursorOffset((displayValues[target] ?? '').length) + }, + [activeField, inputValue, displayValues, buildState, setOAuthStatus], + ) + + const doSave = useCallback(() => { + const finalVals = { ...displayValues, [activeField]: inputValue } + const env: Record = {} + if (finalVals.base_url) env.ANTHROPIC_BASE_URL = finalVals.base_url + if (finalVals.api_key) env.ANTHROPIC_AUTH_TOKEN = finalVals.api_key + if (finalVals.haiku_model) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = finalVals.haiku_model + if (finalVals.sonnet_model) env.ANTHROPIC_DEFAULT_SONNET_MODEL = finalVals.sonnet_model + if (finalVals.opus_model) env.ANTHROPIC_DEFAULT_OPUS_MODEL = finalVals.opus_model + const { error } = updateSettingsForSource('userSettings', { + modelType: 'anthropic' as any, + env, + } as any) + if (error) { + setOAuthStatus({ + state: 'error', + message: 'Failed to save settings. Please try again.', + toRetry: { + state: 'custom_platform', + baseUrl: '', + apiKey: '', + haikuModel: '', + sonnetModel: '', + opusModel: '', + activeField: 'base_url', + }, + }) + } else { + for (const [k, v] of Object.entries(env)) process.env[k] = v + setOAuthStatus({ state: 'success' }) + // Schedule onDone after state update to avoid potential state update on unmounted component + setTimeout(onDone, 0) } - }, - { context: 'Tabs' }, - ); - useKeybinding( - 'tabs:previous', - () => { - const idx = FIELDS.indexOf(activeField); - if (idx > 0) { - setOAuthStatus(buildState(activeField, inputValue, FIELDS[idx - 1])); - setInputValue(displayValues[FIELDS[idx - 1]!] ?? ''); - setInputCursorOffset((displayValues[FIELDS[idx - 1]!] ?? '').length); + }, [activeField, inputValue, displayValues, setOAuthStatus, onDone]) + + const handleEnter = useCallback(() => { + const idx = FIELDS.indexOf(activeField) + setOAuthStatus(buildState(activeField, inputValue)) + if (idx === FIELDS.length - 1) { + doSave() + } else { + const next = FIELDS[idx + 1]! + setInputValue(displayValues[next] ?? '') + setInputCursorOffset((displayValues[next] ?? '').length) } - }, - { context: 'Tabs' }, - ); - useKeybinding( - 'confirm:no', - () => { - setOAuthStatus({ state: 'idle' }); - }, - { context: 'Confirmation' }, - ); + }, [activeField, inputValue, buildState, doSave, displayValues, setOAuthStatus]) - const columns = useTerminalSize().columns - 20; + useKeybinding( + 'tabs:next', + () => { + const idx = FIELDS.indexOf(activeField) + if (idx < FIELDS.length - 1) { + setOAuthStatus(buildState(activeField, inputValue, FIELDS[idx + 1])) + setInputValue(displayValues[FIELDS[idx + 1]!] ?? '') + setInputCursorOffset((displayValues[FIELDS[idx + 1]!] ?? '').length) + } + }, + { context: 'Tabs' }, + ) + useKeybinding( + 'tabs:previous', + () => { + const idx = FIELDS.indexOf(activeField) + if (idx > 0) { + setOAuthStatus(buildState(activeField, inputValue, FIELDS[idx - 1])) + setInputValue(displayValues[FIELDS[idx - 1]!] ?? '') + setInputCursorOffset((displayValues[FIELDS[idx - 1]!] ?? '').length) + } + }, + { context: 'Tabs' }, + ) + useKeybinding( + 'confirm:no', + () => { + setOAuthStatus({ state: 'idle' }) + }, + { context: 'Confirmation' }, + ) - const renderRow = (field: Field, label: string, opts?: { mask?: boolean; placeholder?: string }) => { - const active = activeField === field; - const val = displayValues[field]; - return ( - - - {` ${label} `} - - - {active ? ( - - ) : val ? ( - - {opts?.mask ? val.slice(0, 8) + '\u00b7'.repeat(Math.max(0, val.length - 8)) : val} + const columns = useTerminalSize().columns - 20 + + const renderRow = ( + field: Field, + label: string, + opts?: { mask?: boolean; placeholder?: string }, + ) => { + const active = activeField === field + const val = displayValues[field] + return ( + + + {` ${label} `} - ) : null} - - ); - }; - - return ( - - Anthropic Compatible Setup - - {renderRow('base_url', 'Base URL ')} - {renderRow('api_key', 'API Key ', { mask: true })} - {renderRow('haiku_model', 'Haiku ')} - {renderRow('sonnet_model', 'Sonnet ')} - {renderRow('opus_model', 'Opus ')} - - Tab to switch · Enter on last field to save · Esc to go back - - ); - } - - case 'openai_chat_api': { - type OpenAIField = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; - const OPENAI_FIELDS: OpenAIField[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model']; - const op = oauthStatus as { - state: 'openai_chat_api'; - activeField: OpenAIField; - baseUrl: string; - apiKey: string; - haikuModel: string; - sonnetModel: string; - opusModel: string; - }; - const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = op; - const openaiDisplayValues: Record = { - base_url: baseUrl, - api_key: apiKey, - haiku_model: haikuModel, - sonnet_model: sonnetModel, - opus_model: opusModel, - }; - - const [openaiInputValue, setOpenaiInputValue] = useState(() => openaiDisplayValues[activeField]); - const [openaiInputCursorOffset, setOpenaiInputCursorOffset] = useState( - () => openaiDisplayValues[activeField].length, - ); - - const buildOpenAIState = useCallback( - (field: OpenAIField, value: string, newActive?: OpenAIField) => { - const s = { - state: 'openai_chat_api' as const, - activeField: newActive ?? activeField, - baseUrl, - apiKey, - haikuModel, - sonnetModel, - opusModel, - }; - switch (field) { - case 'base_url': - return { ...s, baseUrl: value }; - case 'api_key': - return { ...s, apiKey: value }; - case 'haiku_model': - return { ...s, haikuModel: value }; - case 'sonnet_model': - return { ...s, sonnetModel: value }; - case 'opus_model': - return { ...s, opusModel: value }; - } - }, - [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel], - ); - - const doOpenAISave = useCallback(() => { - const finalVals = { ...openaiDisplayValues, [activeField]: openaiInputValue }; - const env: Record = {}; - if (finalVals.base_url) env.OPENAI_BASE_URL = finalVals.base_url; - if (finalVals.api_key) env.OPENAI_API_KEY = finalVals.api_key; - if (finalVals.haiku_model) env.OPENAI_DEFAULT_HAIKU_MODEL = finalVals.haiku_model; - if (finalVals.sonnet_model) env.OPENAI_DEFAULT_SONNET_MODEL = finalVals.sonnet_model; - if (finalVals.opus_model) env.OPENAI_DEFAULT_OPUS_MODEL = finalVals.opus_model; - const { error } = updateSettingsForSource('userSettings', { - modelType: 'openai' as any, - env, - } as any); - if (error) { - setOAuthStatus({ - state: 'error', - message: `Failed to save: ${error.message}`, - toRetry: { - state: 'openai_chat_api', - baseUrl: '', - apiKey: '', - haikuModel: '', - sonnetModel: '', - opusModel: '', - activeField: 'base_url', - }, - }); - } else { - for (const [k, v] of Object.entries(env)) process.env[k] = v; - setOAuthStatus({ state: 'success' }); - void onDone(); + + {active ? ( + + ) : val ? ( + + {opts?.mask + ? val.slice(0, 8) + '\u00b7'.repeat(Math.max(0, val.length - 8)) + : val} + + ) : null} + + ) } - }, [activeField, openaiInputValue, openaiDisplayValues, setOAuthStatus, onDone]); - const handleOpenAIEnter = useCallback(() => { - const idx = OPENAI_FIELDS.indexOf(activeField); - setOAuthStatus(buildOpenAIState(activeField, openaiInputValue)); - if (idx === OPENAI_FIELDS.length - 1) { - doOpenAISave(); - } else { - const next = OPENAI_FIELDS[idx + 1]!; - setOpenaiInputValue(openaiDisplayValues[next] ?? ''); - setOpenaiInputCursorOffset((openaiDisplayValues[next] ?? '').length); - } - }, [activeField, openaiInputValue, buildOpenAIState, doOpenAISave, openaiDisplayValues, setOAuthStatus]); - - useKeybinding( - 'tabs:next', - () => { - const idx = OPENAI_FIELDS.indexOf(activeField); - if (idx < OPENAI_FIELDS.length - 1) { - setOAuthStatus(buildOpenAIState(activeField, openaiInputValue, OPENAI_FIELDS[idx + 1])); - setOpenaiInputValue(openaiDisplayValues[OPENAI_FIELDS[idx + 1]!] ?? ''); - setOpenaiInputCursorOffset((openaiDisplayValues[OPENAI_FIELDS[idx + 1]!] ?? '').length); - } - }, - { context: 'Tabs' }, - ); - useKeybinding( - 'tabs:previous', - () => { - const idx = OPENAI_FIELDS.indexOf(activeField); - if (idx > 0) { - setOAuthStatus(buildOpenAIState(activeField, openaiInputValue, OPENAI_FIELDS[idx - 1])); - setOpenaiInputValue(openaiDisplayValues[OPENAI_FIELDS[idx - 1]!] ?? ''); - setOpenaiInputCursorOffset((openaiDisplayValues[OPENAI_FIELDS[idx - 1]!] ?? '').length); - } - }, - { context: 'Tabs' }, - ); - useKeybinding( - 'confirm:no', - () => { - setOAuthStatus({ state: 'idle' }); - }, - { context: 'Confirmation' }, - ); - - const openaiColumns = useTerminalSize().columns - 20; - - const renderOpenAIRow = (field: OpenAIField, label: string, opts?: { mask?: boolean }) => { - const active = activeField === field; - const val = openaiDisplayValues[field]; return ( - - - {` ${label} `} - - - {active ? ( - - ) : val ? ( - - {opts?.mask ? val.slice(0, 8) + '\u00b7'.repeat(Math.max(0, val.length - 8)) : val} - - ) : null} - - ); - }; - - return ( - - OpenAI Compatible API Setup - Configure an OpenAI Chat Completions compatible endpoint (e.g. Ollama, DeepSeek, vLLM). - {renderOpenAIRow('base_url', 'Base URL ')} - {renderOpenAIRow('api_key', 'API Key ', { mask: true })} - {renderOpenAIRow('haiku_model', 'Haiku ')} - {renderOpenAIRow('sonnet_model', 'Sonnet ')} - {renderOpenAIRow('opus_model', 'Opus ')} + Anthropic Compatible Setup + + {renderRow('base_url', 'Base URL ')} + {renderRow('api_key', 'API Key ', { mask: true })} + {renderRow('haiku_model', 'Haiku ')} + {renderRow('sonnet_model', 'Sonnet ')} + {renderRow('opus_model', 'Opus ')} + + + Tab to switch · Enter on last field to save · Esc to go back + - Tab to switch · Enter on last field to save · Esc to go back - - ); - } + ) + } + + case 'openai_chat_api': + { + type OpenAIField = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' + const OPENAI_FIELDS: OpenAIField[] = [ + 'base_url', + 'api_key', + 'haiku_model', + 'sonnet_model', + 'opus_model', + ] + const op = oauthStatus as { + state: 'openai_chat_api' + activeField: OpenAIField + baseUrl: string + apiKey: string + haikuModel: string + sonnetModel: string + opusModel: string + } + const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = op + const openaiDisplayValues: Record = { + base_url: baseUrl, + api_key: apiKey, + haiku_model: haikuModel, + sonnet_model: sonnetModel, + opus_model: opusModel, + } + + const [openaiInputValue, setOpenaiInputValue] = useState( + () => openaiDisplayValues[activeField], + ) + const [openaiInputCursorOffset, setOpenaiInputCursorOffset] = useState( + () => openaiDisplayValues[activeField].length, + ) + + const buildOpenAIState = useCallback( + (field: OpenAIField, value: string, newActive?: OpenAIField) => { + const s = { + state: 'openai_chat_api' as const, + activeField: newActive ?? activeField, + baseUrl, + apiKey, + haikuModel, + sonnetModel, + opusModel, + } + switch (field) { + case 'base_url': + return { ...s, baseUrl: value } + case 'api_key': + return { ...s, apiKey: value } + case 'haiku_model': + return { ...s, haikuModel: value } + case 'sonnet_model': + return { ...s, sonnetModel: value } + case 'opus_model': + return { ...s, opusModel: value } + } + }, + [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel], + ) + + const doOpenAISave = useCallback(() => { + const finalVals = { ...openaiDisplayValues, [activeField]: openaiInputValue } + const env: Record = {} + if (finalVals.base_url) env.OPENAI_BASE_URL = finalVals.base_url + if (finalVals.api_key) env.OPENAI_API_KEY = finalVals.api_key + if (finalVals.haiku_model) env.OPENAI_DEFAULT_HAIKU_MODEL = finalVals.haiku_model + if (finalVals.sonnet_model) env.OPENAI_DEFAULT_SONNET_MODEL = finalVals.sonnet_model + if (finalVals.opus_model) env.OPENAI_DEFAULT_OPUS_MODEL = finalVals.opus_model + const { error } = updateSettingsForSource('userSettings', { + modelType: 'openai' as any, + env, + } as any) + if (error) { + setOAuthStatus({ + state: 'error', + message: 'Failed to save settings. Please try again.', + toRetry: { + state: 'openai_chat_api', + baseUrl: '', + apiKey: '', + haikuModel: '', + sonnetModel: '', + opusModel: '', + activeField: 'base_url', + }, + }) + } else { + for (const [k, v] of Object.entries(env)) process.env[k] = v + setOAuthStatus({ state: 'success' }) + // Schedule onDone after state update to avoid potential state update on unmounted component + setTimeout(onDone, 0) + } + }, [activeField, openaiInputValue, openaiDisplayValues, setOAuthStatus, onDone]) + + const handleOpenAIEnter = useCallback(() => { + const idx = OPENAI_FIELDS.indexOf(activeField) + setOAuthStatus(buildOpenAIState(activeField, openaiInputValue)) + if (idx === OPENAI_FIELDS.length - 1) { + doOpenAISave() + } else { + const next = OPENAI_FIELDS[idx + 1]! + setOpenaiInputValue(openaiDisplayValues[next] ?? '') + setOpenaiInputCursorOffset((openaiDisplayValues[next] ?? '').length) + } + }, [ + activeField, + openaiInputValue, + buildOpenAIState, + doOpenAISave, + openaiDisplayValues, + setOAuthStatus, + ]) + + useKeybinding( + 'tabs:next', + () => { + const idx = OPENAI_FIELDS.indexOf(activeField) + if (idx < OPENAI_FIELDS.length - 1) { + setOAuthStatus( + buildOpenAIState(activeField, openaiInputValue, OPENAI_FIELDS[idx + 1]), + ) + setOpenaiInputValue(openaiDisplayValues[OPENAI_FIELDS[idx + 1]!] ?? '') + setOpenaiInputCursorOffset( + (openaiDisplayValues[OPENAI_FIELDS[idx + 1]!] ?? '').length, + ) + } + }, + { context: 'Tabs' }, + ) + useKeybinding( + 'tabs:previous', + () => { + const idx = OPENAI_FIELDS.indexOf(activeField) + if (idx > 0) { + setOAuthStatus( + buildOpenAIState(activeField, openaiInputValue, OPENAI_FIELDS[idx - 1]), + ) + setOpenaiInputValue(openaiDisplayValues[OPENAI_FIELDS[idx - 1]!] ?? '') + setOpenaiInputCursorOffset( + (openaiDisplayValues[OPENAI_FIELDS[idx - 1]!] ?? '').length, + ) + } + }, + { context: 'Tabs' }, + ) + useKeybinding( + 'confirm:no', + () => { + setOAuthStatus({ state: 'idle' }) + }, + { context: 'Confirmation' }, + ) + + const openaiColumns = useTerminalSize().columns - 20 + + const renderOpenAIRow = ( + field: OpenAIField, + label: string, + opts?: { mask?: boolean }, + ) => { + const active = activeField === field + const val = openaiDisplayValues[field] + return ( + + + {` ${label} `} + + + {active ? ( + + ) : val ? ( + + {opts?.mask + ? val.slice(0, 8) + '\u00b7'.repeat(Math.max(0, val.length - 8)) + : val} + + ) : null} + + ) + } + + return ( + + OpenAI Compatible API Setup + + Configure an OpenAI Chat Completions compatible endpoint (e.g. + Ollama, DeepSeek, vLLM). + + + {renderOpenAIRow('base_url', 'Base URL ')} + {renderOpenAIRow('api_key', 'API Key ', { mask: true })} + {renderOpenAIRow('haiku_model', 'Haiku ')} + {renderOpenAIRow('sonnet_model', 'Sonnet ')} + {renderOpenAIRow('opus_model', 'Opus ')} + + + Tab to switch · Enter on last field to save · Esc to go back + + + ) + } case 'platform_setup': return ( @@ -890,12 +976,14 @@ function OAuthStatusMessage({ - Claude Code supports Amazon Bedrock, Microsoft Foundry, and Vertex AI. Set the required environment - variables, then restart Claude Code. + Claude Code supports Amazon Bedrock, Microsoft Foundry, and Vertex + AI. Set the required environment variables, then restart Claude + Code. - If you are part of an enterprise organization, contact your administrator for setup instructions. + If you are part of an enterprise organization, contact your + administrator for setup instructions. @@ -927,7 +1015,7 @@ function OAuthStatusMessage({ - ); + ) case 'waiting_for_login': return ( @@ -951,7 +1039,9 @@ function OAuthStatusMessage({ handleSubmitCode(value, oauthStatus.url)} + onSubmit={(value: string) => + handleSubmitCode(value, oauthStatus.url) + } cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} columns={textInputColumns} @@ -960,7 +1050,7 @@ function OAuthStatusMessage({ )} - ); + ) case 'creating_api_key': return ( @@ -970,14 +1060,14 @@ function OAuthStatusMessage({ Creating API key for Claude Code… - ); + ) case 'about_to_retry': return ( Retrying… - ); + ) case 'success': return ( @@ -986,7 +1076,8 @@ function OAuthStatusMessage({ <> {getOauthAccountInfo()?.emailAddress ? ( - Logged in as {getOauthAccountInfo()?.emailAddress} + Logged in as{' '} + {getOauthAccountInfo()?.emailAddress} ) : null} @@ -995,7 +1086,7 @@ function OAuthStatusMessage({ )} - ); + ) case 'error': return ( @@ -1010,9 +1101,9 @@ function OAuthStatusMessage({ )} - ); + ) default: - return null; + return null } }