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, useTerminalNotification, Box, Link, Text, KeyboardShortcutHint } from '@anthropic/ink'; import { useKeybinding } from '../keybindings/useKeybinding.js'; import { getSSLErrorHint } from '@ant/model-provider'; import { sendNotification } from '../services/notifier.js'; import { completeChatGPTDeviceLogin, removeChatGPTAuth, requestChatGPTDeviceCode, type ChatGPTDeviceCode, } from '../services/api/openai/chatgptAuth.js'; import { clearOpenAIClientCache } from '../services/api/openai/client.js'; import { OAuthService } from '../services/oauth/index.js'; import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js'; import { openBrowser } from '../utils/browser.js'; import { logError } from '../utils/log.js'; import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js'; import { CHINA_LLM_PROVIDERS, type ProviderPreset, resolveChinaProviderBaseURL } from 'src/utils/chinaLlmProviders.js'; import { Select } from './CustomSelect/select.js'; import { Spinner } from './Spinner.js'; import TextInput from './TextInput.js'; type Props = { 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'; } // 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'; } // OpenAI Chat Completions API platform | { state: 'chatgpt_subscription'; phase: 'requesting' | 'waiting'; deviceCode?: ChatGPTDeviceCode; } // ChatGPT account subscription via Codex OAuth device flow | { state: 'gemini_api'; baseUrl: string; apiKey: string; haikuModel: string; sonnetModel: string; opusModel: string; activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; } // Gemini Generate Content API platform | { state: 'china_provider_select'; activeIndex: number } // China LLM: pick provider | { state: 'china_mode_select'; provider: ProviderPreset; activeIndex: number } // China LLM: pick access mode | { state: 'china_model_select'; provider: ProviderPreset; mode: 'api' | 'coding-plan'; activeIndex: number } // China LLM: pick model | { state: 'china_apikey'; provider: ProviderPreset; mode: 'api' | 'coding-plan'; modelId: string; apiKey: string } // China LLM: enter API key | { state: 'ready_to_start' } // Flow started, waiting for browser to open | { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login | { state: 'creating_api_key' } // Got access token, creating API key | { state: 'about_to_retry'; nextState: OAuthStatus } | { state: 'success'; token?: string } | { state: 'error'; message: string; toRetry?: OAuthStatus; }; const PASTE_HERE_MSG = 'Paste code here if prompted > '; export function ConsoleOAuthFlow({ onDone, startingMessage, mode = 'login', forceLoginMethod: forceLoginMethodProp, }: Props): React.ReactNode { 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; const terminal = useTerminalNotification(); const [oauthStatus, setOAuthStatus] = useState(() => { if (mode === 'setup-token') { return { state: 'ready_to_start' }; } if (forceLoginMethod === 'claudeai' || forceLoginMethod === 'console') { return { state: 'ready_to_start' }; } return { state: 'idle' }; }); 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'; }); // 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 textInputColumns = useTerminalSize().columns - PASTE_HERE_MSG.length - 1; // Log forced login method on mount useEffect(() => { if (forceLoginMethod === 'claudeai') { logEvent('tengu_oauth_claudeai_forced', {}); } else if (forceLoginMethod === 'console') { logEvent('tengu_oauth_console_forced', {}); } }, [forceLoginMethod]); // Retry logic useEffect(() => { if (oauthStatus.state === 'about_to_retry') { const timer = setTimeout(setOAuthStatus, 1000, oauthStatus.nextState); return () => clearTimeout(timer); } }, [oauthStatus]); // Handle Enter to continue on success state useKeybinding( 'confirm:yes', () => { 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' }); }, { context: 'Confirmation', isActive: oauthStatus.state === 'platform_setup', }, ); // Handle Enter to retry on error state useKeybinding( 'confirm:yes', () => { if (oauthStatus.state === 'error' && oauthStatus.toRetry) { 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) { void setClipboard(oauthStatus.url).then(raw => { if (raw) process.stdout.write(raw); setUrlCopied(true); setTimeout(setUrlCopied, 2000, false); }); setPastedCode(''); } }, [pastedCode, oauthStatus, showPastePrompt, urlCopied]); async function handleSubmitCode(value: string, url: string) { try { // Expecting format "authorizationCode#state" from the authorization callback URL 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; } // Track which path the user is taking (manual code entry) logEvent('tengu_oauth_manual_entry', {}); oauthService.handleManualAuthCodeInput({ authorizationCode, state, }); } catch (err: unknown) { 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 }); const result = await oauthService .startOAuthFlow( async url => { setOAuthStatus({ state: 'waiting_for_login', url }); setTimeout(setShowPastePrompt, 3000, true); }, { loginWithClaudeAi, inferenceOnly: mode === 'setup-token', expiresIn: mode === 'setup-token' ? 365 * 24 * 60 * 60 : undefined, // 1 year for setup-token orgUUID, }, ) .catch(err => { 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); setOAuthStatus({ state: 'error', message: sslHint ?? (isTokenExchangeError ? 'Failed to exchange authorization code for access token. Please try again.' : err.message), 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; }); 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 }); } else { await installOAuthTokens(result); const orgResult = await validateForceLoginOrg(); if (!orgResult.valid) { throw new Error((orgResult as { valid: false; message: string }).message); } // Reset modelType to anthropic when using OAuth login updateSettingsForSource('userSettings', { modelType: 'anthropic' } as unknown as Parameters< typeof updateSettingsForSource >[1]); 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); 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, ssl_error: sslHint !== null, }); } }, [oauthService, setShowPastePrompt, loginWithClaudeAi, mode, orgUUID]); const pendingOAuthStartRef = useRef(false); useEffect(() => { 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]); // Auto-exit for setup-token mode useEffect(() => { if (mode === 'setup-token' && oauthStatus.state === 'success') { // Delay to ensure static content is fully rendered before exiting const timer = setTimeout( (loginWithClaudeAi, onDone) => { logEvent('tengu_oauth_success', { loginWithClaudeAi }); // Don't clear terminal so the token remains visible onDone(); }, 500, loginWithClaudeAi, onDone, ); return () => clearTimeout(timer); } }, [mode, oauthStatus, loginWithClaudeAi, onDone]); // Cleanup OAuth service when component unmounts useEffect(() => { return () => { oauthService.cleanup(); }; }, [oauthService]); return ( {oauthStatus.state === 'waiting_for_login' && showPastePrompt && ( Browser didn't open? Use the url below to sign in {urlCopied ? ( (Copied!) ) : ( )} {oauthStatus.url} )} {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; }; function OAuthStatusMessage({ oauthStatus, mode, startingMessage, forcedMethodMessage, showPastePrompt, pastedCode, setPastedCode, cursorOffset, setCursorOffset, textInputColumns, handleSubmitCode, setOAuthStatus, setLoginWithClaudeAi, onDone, }: OAuthStatusMessageProps): React.ReactNode { switch (oauthStatus.state) { case 'idle': return ( {startingMessage ? startingMessage : `Claude Code can be used with your Claude subscription or billed based on API usage through your Console account.`} Select login method: ({ label: ( {p.icon} {p.label} · {p.description} {'\n'} ), value: p.id, }))} onChange={value => { const provider = CHINA_LLM_PROVIDERS.find(p => p.id === value); if (!provider) return; logEvent('tengu_china_provider_selected', {}); if (provider.codingPlan) { setOAuthStatus({ state: 'china_mode_select', provider, activeIndex: 0 }); } else { setOAuthStatus({ state: 'china_model_select', provider, mode: 'api', activeIndex: 0 }); } }} /> ); } case 'china_mode_select': { const { provider } = oauthStatus; const modeOptions = [ { id: 'api' as const, label: 'Pay-as-you-go (API)', desc: 'Top up freely, pay per use' }, { id: 'coding-plan' as const, label: 'Coding Plan', desc: 'Fixed monthly fee, high usage' }, ]; return ( {provider.icon} {provider.label} — Select Access Mode { const priceLabel = m.inputPricePerMTok === 0 && m.outputPricePerMTok === 0 ? 'Free' : `¥${m.inputPricePerMTok}/¥${m.outputPricePerMTok}`; const tagLabel = m.tags?.length ? ` [${m.tags.join(', ')}]` : ''; return { label: ( {m.label} ·{' '} {priceLabel} · {m.contextWindow} {tagLabel} {'\n'} ), value: m.id, }; }), { label: ( ✏️ Custom model · enter model name manually {'\n'} ), value: '__custom__', }, ]} onChange={value => { logEvent('tengu_china_model_selected', {}); setOAuthStatus({ state: 'china_apikey', provider, mode: accessMode, modelId: value, apiKey: '' }); }} /> ); } case 'china_apikey': { const { provider, mode: accessMode, modelId } = oauthStatus; const [chinaKeyValue, setChinaKeyValue] = useState(''); const [chinaKeyCursor, setChinaKeyCursor] = useState(0); const [chinaKeyError, setChinaKeyError] = useState(null); const doChinaSave = useCallback(() => { const effectiveModelId = modelId === '__custom__' ? chinaKeyValue.trim() : modelId; if (!effectiveModelId) { setChinaKeyError(modelId === '__custom__' ? 'Please enter a model name' : 'Please enter an API key'); return; } if (modelId === '__custom__') { logEvent('tengu_china_custom_model_entered', {}); setOAuthStatus({ state: 'china_apikey', provider, mode: accessMode, modelId: effectiveModelId, apiKey: '' }); setChinaKeyValue(''); setChinaKeyError(null); return; } if (!chinaKeyValue.trim()) { setChinaKeyError('Please enter an API key'); return; } const baseUrl = resolveChinaProviderBaseURL(provider.id, accessMode); const env: Record = { OPENAI_AUTH_MODE: undefined, OPENAI_BASE_URL: baseUrl, OPENAI_API_KEY: chinaKeyValue.trim(), OPENAI_DEFAULT_SONNET_MODEL: modelId, OPENAI_DEFAULT_HAIKU_MODEL: modelId, OPENAI_DEFAULT_OPUS_MODEL: modelId, }; const settingsUpdate: Parameters[1] = { modelType: 'openai', env: env as unknown as Record, }; const { error } = updateSettingsForSource('userSettings', settingsUpdate); if (error) { setOAuthStatus({ state: 'error', message: 'Failed to save settings. Please try again.', toRetry: { state: 'china_apikey', provider, mode: accessMode, modelId, apiKey: chinaKeyValue }, }); } else { for (const [k, v] of Object.entries(env)) { if (v === undefined) { delete process.env[k]; } else { process.env[k] = v; } } // Drop any cached OpenAI client and ChatGPT auth so the new // provider/credentials take effect on the next request. clearOpenAIClientCache(); void removeChatGPTAuth().catch(() => {}); logEvent('tengu_china_login_success', {}); setOAuthStatus({ state: 'success' }); void onDone(); } }, [chinaKeyValue, provider, accessMode, modelId, onDone, setOAuthStatus]); useKeybinding( 'confirm:no', () => { setOAuthStatus({ state: 'china_model_select', provider, mode: accessMode, activeIndex: 0 }); }, { context: 'Confirmation' }, ); const isCustomModelEntry = modelId === '__custom__'; const allModels = CHINA_LLM_PROVIDERS.flatMap(p => p.models.map(m => ({ id: m.id, label: m.label, provider: p.label })), ); const modelSuggestions = isCustomModelEntry ? chinaKeyValue.trim() ? allModels.filter(m => m.id.toLowerCase().includes(chinaKeyValue.trim().toLowerCase())) : allModels : []; const keyPage = isCustomModelEntry ? provider.apiKeyPage : accessMode === 'coding-plan' && provider.codingPlan ? provider.codingPlan.purchasePage : provider.apiKeyPage; const keyFormat = isCustomModelEntry ? provider.keyFormat : accessMode === 'coding-plan' && provider.codingPlan ? provider.codingPlan.keyFormat : provider.keyFormat; return ( {provider.icon} {provider.label} {isCustomModelEntry ? '— Custom Model' : 'API Key'} {isCustomModelEntry ? ( Enter any model ID supported by this provider. Browse models: {provider.modelsPage} ) : ( <> Get your key: {keyPage} {' '} {accessMode === 'coding-plan' ? 'Use your Coding Plan credential here' : provider.freeTier} Key format: {keyFormat} )} {isCustomModelEntry ? 'Model name: ' : 'API Key: '} { setChinaKeyValue(v); setChinaKeyError(null); }} onSubmit={doChinaSave} cursorOffset={chinaKeyCursor} onChangeCursorOffset={setChinaKeyCursor} columns={useTerminalSize().columns - 12} mask={isCustomModelEntry ? undefined : '*'} focus={true} /> {chinaKeyError ? {chinaKeyError} : null} {isCustomModelEntry && modelSuggestions.length > 0 && ( {chinaKeyValue.trim() ? 'Matching models:' : 'Known models:'} {modelSuggestions.map(m => ( {' '} {m.id}{' '} ({m.label} — {m.provider}) ))} )} {isCustomModelEntry ? 'Enter to continue · Esc to go back' : 'Enter to confirm · Esc to go back'} ); } case 'platform_setup': return ( Using 3rd-party platforms 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. Documentation: · Amazon Bedrock:{' '} https://code.claude.com/docs/en/amazon-bedrock · Microsoft Foundry:{' '} https://code.claude.com/docs/en/microsoft-foundry · Vertex AI:{' '} https://code.claude.com/docs/en/google-vertex-ai Press Enter to go back to login options. ); case 'waiting_for_login': return ( {forcedMethodMessage && ( {forcedMethodMessage} )} {!showPastePrompt && ( Opening browser to sign in… )} {showPastePrompt && ( {PASTE_HERE_MSG} handleSubmitCode(value, oauthStatus.url)} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} columns={textInputColumns} mask="*" /> )} ); case 'creating_api_key': return ( Creating API key for Claude Code… ); case 'about_to_retry': return ( Retrying… ); case 'success': return ( {mode === 'setup-token' && oauthStatus.token ? null : ( <> {getOauthAccountInfo()?.emailAddress ? ( Logged in as {getOauthAccountInfo()?.emailAddress} ) : null} Login successful. Press Enter to continue… )} ); case 'error': return ( OAuth error: {oauthStatus.message} {oauthStatus.toRetry && ( Press Enter to retry. )} ); default: return null; } }