Files
claude-code/src/components/ConsoleOAuthFlow.tsx
claude-code-best b83395cdfe fix: 登录后清理 OpenAI 客户端缓存和 ChatGPT auth 防止凭证泄漏
OpenAI Compatible / ChatGPT Subscription / 中国区登录成功后,清除
缓存的 OpenAI 客户端实例和 ChatGPT auth 文件,确保下次请求使用新凭证。

Co-Authored-By: deepseek-v4-pro <deepseek-ai@claude-code-best.win>
2026-06-16 19:40:25 +08:00

1711 lines
62 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<OAuthStatus>(() => {
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 (
<Box flexDirection="column" gap={1}>
{oauthStatus.state === 'waiting_for_login' && showPastePrompt && (
<Box flexDirection="column" key="urlToCopy" gap={1} paddingBottom={1}>
<Box paddingX={1}>
<Text dimColor>Browser didn&apos;t open? Use the url below to sign in </Text>
{urlCopied ? (
<Text color="success">(Copied!)</Text>
) : (
<Text dimColor>
<KeyboardShortcutHint shortcut="c" action="copy" parens />
</Text>
)}
</Box>
<Link url={oauthStatus.url}>
<Text dimColor>{oauthStatus.url}</Text>
</Link>
</Box>
)}
{mode === 'setup-token' && oauthStatus.state === 'success' && oauthStatus.token && (
<Box key="tokenOutput" flexDirection="column" gap={1} paddingTop={1}>
<Text color="success"> Long-lived authentication token created successfully!</Text>
<Box flexDirection="column" gap={1}>
<Text>Your OAuth token (valid for 1 year):</Text>
<Text color="warning">{oauthStatus.token}</Text>
<Text dimColor>Store this token securely. You won&apos;t be able to see it again.</Text>
<Text dimColor>Use this token by setting: export CLAUDE_CODE_OAUTH_TOKEN=&lt;token&gt;</Text>
</Box>
</Box>
)}
<Box paddingLeft={1} flexDirection="column" gap={1}>
<OAuthStatusMessage
oauthStatus={oauthStatus}
mode={mode}
startingMessage={startingMessage}
forcedMethodMessage={forcedMethodMessage}
showPastePrompt={showPastePrompt}
pastedCode={pastedCode}
setPastedCode={setPastedCode}
cursorOffset={cursorOffset}
setCursorOffset={setCursorOffset}
textInputColumns={textInputColumns}
handleSubmitCode={handleSubmitCode}
setOAuthStatus={setOAuthStatus}
setLoginWithClaudeAi={setLoginWithClaudeAi}
onDone={onDone}
/>
</Box>
</Box>
);
}
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 (
<Box flexDirection="column" gap={1} marginTop={1}>
<Text bold>
{startingMessage
? startingMessage
: `Claude Code can be used with your Claude subscription or billed based on API usage through your Console account.`}
</Text>
<Text>Select login method:</Text>
<Box>
<Select
options={[
{
label: (
<Text>
Anthropic Compatible · <Text dimColor>Configure your own API endpoint</Text>
{'\n'}
</Text>
),
value: 'custom_platform',
},
{
label: (
<Text>
OpenAI Compatible · <Text dimColor>Ollama, DeepSeek, vLLM, One API, etc.</Text>
{'\n'}
</Text>
),
value: 'openai_chat_api',
},
{
label: (
<Text>
China LLM Providers · <Text dimColor>DeepSeek, Zhipu GLM, Qwen, MiMo</Text>
{'\n'}
</Text>
),
value: 'china_providers',
},
{
label: (
<Text>
ChatGPT account with subscription · <Text dimColor>Plus, Pro, Business, Edu, or Enterprise</Text>
{'\n'}
</Text>
),
value: 'chatgpt_subscription',
},
{
label: (
<Text>
Gemini API · <Text dimColor>Google Gemini native REST/SSE</Text>
{'\n'}
</Text>
),
value: 'gemini_api',
},
{
label: (
<Text>
Claude account with subscription · <Text dimColor>Pro, Max, Team, or Enterprise</Text>
{process.env.USER_TYPE === 'ant' && (
<Text>
{'\n'}
<Text color="warning">[ANT-ONLY]</Text>{' '}
<Text dimColor>
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
</Text>
</Text>
)}
{'\n'}
</Text>
),
value: 'claudeai',
},
{
label: (
<Text>
Anthropic Console account · <Text dimColor>API usage billing</Text>
{'\n'}
</Text>
),
value: 'console',
},
{
label: (
<Text>
3rd-party platform · <Text dimColor>Amazon Bedrock, Microsoft Foundry, or Vertex AI</Text>
{'\n'}
</Text>
),
value: 'platform',
},
]}
onChange={value => {
if (value === 'custom_platform') {
logEvent('tengu_custom_platform_selected', {});
setOAuthStatus({
state: 'custom_platform',
baseUrl: process.env.ANTHROPIC_BASE_URL ?? '',
apiKey: process.env.ANTHROPIC_AUTH_TOKEN ?? '',
haikuModel: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL ?? '',
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', {});
setOAuthStatus({
state: 'openai_chat_api',
baseUrl: process.env.OPENAI_BASE_URL ?? '',
apiKey: process.env.OPENAI_API_KEY ?? '',
haikuModel: process.env.OPENAI_DEFAULT_HAIKU_MODEL ?? '',
sonnetModel: process.env.OPENAI_DEFAULT_SONNET_MODEL ?? '',
opusModel: process.env.OPENAI_DEFAULT_OPUS_MODEL ?? '',
activeField: 'base_url',
});
} else if (value === 'china_providers') {
logEvent('tengu_china_providers_selected', {});
setOAuthStatus({ state: 'china_provider_select', activeIndex: 0 });
} else if (value === 'chatgpt_subscription') {
logEvent('tengu_chatgpt_subscription_selected', {});
setOAuthStatus({
state: 'chatgpt_subscription',
phase: 'requesting',
});
} else if (value === 'gemini_api') {
logEvent('tengu_gemini_api_selected', {});
setOAuthStatus({
state: 'gemini_api',
baseUrl: process.env.GEMINI_BASE_URL ?? '',
apiKey: process.env.GEMINI_API_KEY ?? '',
haikuModel: process.env.GEMINI_DEFAULT_HAIKU_MODEL ?? '',
sonnetModel: process.env.GEMINI_DEFAULT_SONNET_MODEL ?? '',
opusModel: process.env.GEMINI_DEFAULT_OPUS_MODEL ?? '',
activeField: 'base_url',
});
} else if (value === 'platform') {
logEvent('tengu_oauth_platform_selected', {});
setOAuthStatus({ state: 'platform_setup' });
} else {
setOAuthStatus({ state: 'ready_to_start' });
if (value === 'claudeai') {
logEvent('tengu_oauth_claudeai_selected', {});
setLoginWithClaudeAi(true);
} else {
logEvent('tengu_oauth_console_selected', {});
setLoginWithClaudeAi(false);
}
}
}}
/>
</Box>
</Box>
);
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<Field, string> = {
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<string, string> = {};
// Validate base_url if provided
if (finalVals.base_url) {
try {
new URL(finalVals.base_url);
} catch {
setOAuthStatus({
state: 'error',
message: 'Invalid base URL: please enter a full URL including protocol (e.g., https://api.example.com)',
toRetry: {
state: 'custom_platform',
baseUrl: '',
apiKey: '',
haikuModel: '',
sonnetModel: '',
opusModel: '',
activeField: 'base_url',
},
});
return;
}
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',
env,
} as unknown as Parameters<typeof updateSettingsForSource>[1]);
if (error) {
setOAuthStatus({
state: 'error',
message: 'Failed to save settings. Please try again.',
toRetry: {
state: 'custom_platform',
baseUrl: finalVals.base_url ?? '',
apiKey: finalVals.api_key ?? '',
haikuModel: finalVals.haiku_model ?? '',
sonnetModel: finalVals.sonnet_model ?? '',
opusModel: finalVals.opus_model ?? '',
activeField: 'base_url',
},
});
} else {
for (const [k, v] of Object.entries(env)) process.env[k] = v;
setOAuthStatus({ state: 'success' });
void onDone();
}
}, [activeField, inputValue, displayValues, setOAuthStatus, onDone]);
const handleEnter = useCallback(() => {
const idx = FIELDS.indexOf(activeField);
if (idx === FIELDS.length - 1) {
setOAuthStatus(buildState(activeField, inputValue));
doSave();
} else {
const next = FIELDS[idx + 1]!;
setOAuthStatus(buildState(activeField, inputValue, next));
setInputValue(displayValues[next] ?? '');
setInputCursorOffset((displayValues[next] ?? '').length);
}
}, [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);
}
},
{ context: 'FormField' },
);
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: 'FormField' },
);
useKeybinding(
'confirm:no',
() => {
setOAuthStatus({ state: 'idle' });
},
{ context: 'Confirmation' },
);
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 (
<Box>
<Text backgroundColor={active ? 'suggestion' : undefined} color={active ? 'inverseText' : undefined}>
{` ${label} `}
</Text>
<Text> </Text>
{active ? (
<TextInput
value={inputValue}
onChange={setInputValue}
onSubmit={handleEnter}
cursorOffset={inputCursorOffset}
onChangeCursorOffset={setInputCursorOffset}
columns={columns}
mask={opts?.mask ? '*' : undefined}
focus={true}
/>
) : val ? (
<Text color="success">
{opts?.mask ? val.slice(0, 8) + '\u00b7'.repeat(Math.max(0, val.length - 8)) : val}
</Text>
) : null}
</Box>
);
};
return (
<Box flexDirection="column" gap={1}>
<Text bold>Anthropic Compatible Setup</Text>
<Box flexDirection="column" gap={1}>
{renderRow('base_url', 'Base URL ')}
{renderRow('api_key', 'API Key ', { mask: true })}
{renderRow('haiku_model', 'Haiku ')}
{renderRow('sonnet_model', 'Sonnet ')}
{renderRow('opus_model', 'Opus ')}
</Box>
<Text dimColor>/Tab to switch · Enter on last field to save · Esc to go back</Text>
</Box>
);
}
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<OpenAIField, string> = {
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<string, string | undefined> = {
OPENAI_AUTH_MODE: undefined,
};
// Validate base_url if provided
if (finalVals.base_url) {
try {
new URL(finalVals.base_url);
} catch {
setOAuthStatus({
state: 'error',
message: 'Invalid base URL: please enter a full URL including protocol (e.g., https://api.example.com)',
toRetry: {
state: 'openai_chat_api',
baseUrl: '',
apiKey: '',
haikuModel: '',
sonnetModel: '',
opusModel: '',
activeField: 'base_url',
},
});
return;
}
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 settingsUpdate: Parameters<typeof updateSettingsForSource>[1] = {
modelType: 'openai',
env: env as unknown as Record<string, string>,
};
const { error } = updateSettingsForSource('userSettings', settingsUpdate);
if (error) {
setOAuthStatus({
state: 'error',
message: 'Failed to save settings. Please try again.',
toRetry: {
state: 'openai_chat_api',
baseUrl: finalVals.base_url ?? '',
apiKey: finalVals.api_key ?? '',
haikuModel: finalVals.haiku_model ?? '',
sonnetModel: finalVals.sonnet_model ?? '',
opusModel: finalVals.opus_model ?? '',
activeField: 'base_url',
},
});
} 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 so the next request rebuilds it
// with the new env vars. Also clear ChatGPT auth file so a prior
// ChatGPT Subscription login can't leak into the OpenAI Compatible path.
clearOpenAIClientCache();
void removeChatGPTAuth().catch(() => {});
setOAuthStatus({ state: 'success' });
void onDone();
}
}, [activeField, openaiInputValue, openaiDisplayValues, setOAuthStatus, onDone]);
const handleOpenAIEnter = useCallback(() => {
const idx = OPENAI_FIELDS.indexOf(activeField);
if (idx === OPENAI_FIELDS.length - 1) {
setOAuthStatus(buildOpenAIState(activeField, openaiInputValue));
doOpenAISave();
} else {
const next = OPENAI_FIELDS[idx + 1]!;
setOAuthStatus(buildOpenAIState(activeField, openaiInputValue, next));
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: 'FormField' },
);
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: 'FormField' },
);
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 (
<Box>
<Text backgroundColor={active ? 'suggestion' : undefined} color={active ? 'inverseText' : undefined}>
{` ${label} `}
</Text>
<Text> </Text>
{active ? (
<TextInput
value={openaiInputValue}
onChange={setOpenaiInputValue}
onSubmit={handleOpenAIEnter}
cursorOffset={openaiInputCursorOffset}
onChangeCursorOffset={setOpenaiInputCursorOffset}
columns={openaiColumns}
mask={opts?.mask ? '*' : undefined}
focus={true}
/>
) : val ? (
<Text color="success">
{opts?.mask ? val.slice(0, 8) + '\u00b7'.repeat(Math.max(0, val.length - 8)) : val}
</Text>
) : null}
</Box>
);
};
return (
<Box flexDirection="column" gap={1}>
<Text bold>OpenAI Compatible API Setup</Text>
<Text dimColor>Configure an OpenAI Chat Completions compatible endpoint (e.g. Ollama, DeepSeek, vLLM).</Text>
<Box flexDirection="column" gap={1}>
{renderOpenAIRow('base_url', 'Base URL ')}
{renderOpenAIRow('api_key', 'API Key ', { mask: true })}
{renderOpenAIRow('haiku_model', 'Haiku ')}
{renderOpenAIRow('sonnet_model', 'Sonnet ')}
{renderOpenAIRow('opus_model', 'Opus ')}
</Box>
<Text dimColor>/Tab to switch · Enter on last field to save · Esc to go back</Text>
</Box>
);
}
case 'chatgpt_subscription': {
const status = oauthStatus as {
state: 'chatgpt_subscription';
phase: 'requesting' | 'waiting';
deviceCode?: ChatGPTDeviceCode;
};
const startedRef = useRef(false);
useEffect(() => {
if (startedRef.current) return;
startedRef.current = true;
let cancelled = false;
const controller = new AbortController();
async function runLogin() {
try {
const deviceCode = await requestChatGPTDeviceCode();
if (cancelled) return;
setOAuthStatus({
state: 'chatgpt_subscription',
phase: 'waiting',
deviceCode,
});
void openBrowser(deviceCode.verificationUrl);
await completeChatGPTDeviceLogin(deviceCode, controller.signal);
if (cancelled) return;
const env: Record<string, string> = {
OPENAI_AUTH_MODE: 'chatgpt',
};
const settingsUpdate: Parameters<typeof updateSettingsForSource>[1] = {
modelType: 'openai',
env,
};
const { error } = updateSettingsForSource('userSettings', settingsUpdate);
if (error) {
throw new Error('Failed to save settings. Please try again.');
}
for (const [k, v] of Object.entries(env)) process.env[k] = v;
// Drop any cached OpenAI client built from prior OpenAI Compatible
// env vars; the ChatGPT Subscription path bypasses the SDK client
// entirely (uses createChatGPTResponsesStream) but a stale cached
// client would still be picked up by sideQuery.
clearOpenAIClientCache();
setOAuthStatus({ state: 'success' });
void onDone();
} catch (err) {
if (cancelled) return;
setOAuthStatus({
state: 'error',
message: (err as Error).message,
toRetry: {
state: 'chatgpt_subscription',
phase: 'requesting',
},
});
}
}
void runLogin();
return () => {
cancelled = true;
controller.abort();
};
}, [setOAuthStatus, onDone]);
return (
<Box flexDirection="column" gap={1}>
<Text bold>ChatGPT Account Setup</Text>
{status.phase === 'requesting' && (
<Box>
<Spinner />
<Text>Requesting sign-in code</Text>
</Box>
)}
{status.phase === 'waiting' && status.deviceCode && (
<Box flexDirection="column" gap={1}>
<Text>Open this link and sign in with your ChatGPT account:</Text>
<Link url={status.deviceCode.verificationUrl}>
<Text dimColor>{status.deviceCode.verificationUrl}</Text>
</Link>
<Text>
Enter code: <Text bold>{status.deviceCode.userCode}</Text>
</Text>
<Box>
<Spinner />
<Text>Waiting for ChatGPT authorization</Text>
</Box>
</Box>
)}
<Text dimColor>Esc to go back. Device codes expire after 15 minutes.</Text>
</Box>
);
}
case 'gemini_api': {
type GeminiField = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model';
const GEMINI_FIELDS: GeminiField[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model'];
const gp = oauthStatus as {
state: 'gemini_api';
activeField: GeminiField;
baseUrl: string;
apiKey: string;
haikuModel: string;
sonnetModel: string;
opusModel: string;
};
const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = gp;
const geminiDisplayValues: Record<GeminiField, string> = {
base_url: baseUrl,
api_key: apiKey,
haiku_model: haikuModel,
sonnet_model: sonnetModel,
opus_model: opusModel,
};
const [geminiInputValue, setGeminiInputValue] = useState(() => geminiDisplayValues[activeField]);
const [geminiInputCursorOffset, setGeminiInputCursorOffset] = useState(
() => geminiDisplayValues[activeField].length,
);
const buildGeminiState = useCallback(
(field: GeminiField, value: string, newActive?: GeminiField) => {
const s = {
state: 'gemini_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 doGeminiSave = useCallback(() => {
const finalVals = { ...geminiDisplayValues, [activeField]: geminiInputValue };
if (!finalVals.haiku_model || !finalVals.sonnet_model || !finalVals.opus_model) {
setOAuthStatus({
state: 'error',
message: 'Gemini setup requires Haiku, Sonnet, and Opus model names.',
toRetry: {
state: 'gemini_api',
baseUrl: finalVals.base_url,
apiKey: finalVals.api_key,
haikuModel: finalVals.haiku_model,
sonnetModel: finalVals.sonnet_model,
opusModel: finalVals.opus_model,
activeField,
},
});
return;
}
const env: Record<string, string> = {};
if (finalVals.base_url) env.GEMINI_BASE_URL = finalVals.base_url;
if (finalVals.api_key) env.GEMINI_API_KEY = finalVals.api_key;
if (finalVals.haiku_model) env.GEMINI_DEFAULT_HAIKU_MODEL = finalVals.haiku_model;
if (finalVals.sonnet_model) env.GEMINI_DEFAULT_SONNET_MODEL = finalVals.sonnet_model;
if (finalVals.opus_model) env.GEMINI_DEFAULT_OPUS_MODEL = finalVals.opus_model;
const { error } = updateSettingsForSource('userSettings', {
modelType: 'gemini',
env,
} as unknown as Parameters<typeof updateSettingsForSource>[1]);
if (error) {
setOAuthStatus({
state: 'error',
message: `Failed to save: ${error.message}`,
toRetry: {
state: 'gemini_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();
}
}, [activeField, geminiInputValue, geminiDisplayValues, onDone, setOAuthStatus]);
const handleGeminiEnter = useCallback(() => {
const idx = GEMINI_FIELDS.indexOf(activeField);
if (idx === GEMINI_FIELDS.length - 1) {
setOAuthStatus(buildGeminiState(activeField, geminiInputValue));
doGeminiSave();
} else {
const next = GEMINI_FIELDS[idx + 1]!;
setOAuthStatus(buildGeminiState(activeField, geminiInputValue, next));
setGeminiInputValue(geminiDisplayValues[next] ?? '');
setGeminiInputCursorOffset((geminiDisplayValues[next] ?? '').length);
}
}, [activeField, buildGeminiState, doGeminiSave, geminiDisplayValues, geminiInputValue, setOAuthStatus]);
useKeybinding(
'tabs:next',
() => {
const idx = GEMINI_FIELDS.indexOf(activeField);
if (idx < GEMINI_FIELDS.length - 1) {
setOAuthStatus(buildGeminiState(activeField, geminiInputValue, GEMINI_FIELDS[idx + 1]));
setGeminiInputValue(geminiDisplayValues[GEMINI_FIELDS[idx + 1]!] ?? '');
setGeminiInputCursorOffset((geminiDisplayValues[GEMINI_FIELDS[idx + 1]!] ?? '').length);
}
},
{ context: 'FormField' },
);
useKeybinding(
'tabs:previous',
() => {
const idx = GEMINI_FIELDS.indexOf(activeField);
if (idx > 0) {
setOAuthStatus(buildGeminiState(activeField, geminiInputValue, GEMINI_FIELDS[idx - 1]));
setGeminiInputValue(geminiDisplayValues[GEMINI_FIELDS[idx - 1]!] ?? '');
setGeminiInputCursorOffset((geminiDisplayValues[GEMINI_FIELDS[idx - 1]!] ?? '').length);
}
},
{ context: 'FormField' },
);
useKeybinding(
'confirm:no',
() => {
setOAuthStatus({ state: 'idle' });
},
{ context: 'Confirmation' },
);
const geminiColumns = useTerminalSize().columns - 20;
const renderGeminiRow = (field: GeminiField, label: string, opts?: { mask?: boolean }) => {
const active = activeField === field;
const val = geminiDisplayValues[field];
return (
<Box>
<Text backgroundColor={active ? 'suggestion' : undefined} color={active ? 'inverseText' : undefined}>
{` ${label} `}
</Text>
<Text> </Text>
{active ? (
<TextInput
value={geminiInputValue}
onChange={setGeminiInputValue}
onSubmit={handleGeminiEnter}
cursorOffset={geminiInputCursorOffset}
onChangeCursorOffset={setGeminiInputCursorOffset}
columns={geminiColumns}
mask={opts?.mask ? '*' : undefined}
focus={true}
/>
) : val ? (
<Text color="success">
{opts?.mask ? val.slice(0, 8) + '\u00b7'.repeat(Math.max(0, val.length - 8)) : val}
</Text>
) : null}
</Box>
);
};
return (
<Box flexDirection="column" gap={1}>
<Text bold>Gemini API Setup</Text>
<Text dimColor>
Configure a Gemini Generate Content compatible endpoint. Base URL is optional and defaults to Google&apos;s
v1beta API.
</Text>
<Box flexDirection="column" gap={1}>
{renderGeminiRow('base_url', 'Base URL ')}
{renderGeminiRow('api_key', 'API Key ', { mask: true })}
{renderGeminiRow('haiku_model', 'Haiku ')}
{renderGeminiRow('sonnet_model', 'Sonnet ')}
{renderGeminiRow('opus_model', 'Opus ')}
</Box>
<Text dimColor>/Tab to switch · Enter on last field to save · Esc to go back</Text>
</Box>
);
}
case 'china_provider_select': {
return (
<Box flexDirection="column" gap={1} marginTop={1}>
<Text bold>Select China LLM Provider</Text>
<Text dimColor>Direct connection, no proxy needed. All providers are OpenAI-compatible.</Text>
<Box>
<Select
options={CHINA_LLM_PROVIDERS.map(p => ({
label: (
<Text>
{p.icon} {p.label} · <Text dimColor>{p.description}</Text>
{'\n'}
</Text>
),
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 });
}
}}
/>
</Box>
</Box>
);
}
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 (
<Box flexDirection="column" gap={1} marginTop={1}>
<Text bold>
{provider.icon} {provider.label} Select Access Mode
</Text>
<Box>
<Select
options={modeOptions.map(m => ({
label: (
<Text>
{m.label} · <Text dimColor>{m.desc}</Text>
{'\n'}
</Text>
),
value: m.id,
}))}
onChange={value => {
logEvent('tengu_china_mode_selected', {});
setOAuthStatus({
state: 'china_model_select',
provider,
mode: value as 'api' | 'coding-plan',
activeIndex: 0,
});
}}
/>
</Box>
<Text dimColor>
No plan? Select "Pay-as-you-go"
{provider.id === 'zhipu' ? ' · GLM-4.7-Flash is free forever' : ''}
</Text>
</Box>
);
}
case 'china_model_select': {
const { provider, mode: accessMode } = oauthStatus;
const models = provider.models;
return (
<Box flexDirection="column" gap={1} marginTop={1}>
<Text bold>
{provider.icon} {provider.label} Select Model
</Text>
<Box>
<Select
options={[
...models.map(m => {
const priceLabel =
m.inputPricePerMTok === 0 && m.outputPricePerMTok === 0
? 'Free'
: `¥${m.inputPricePerMTok}${m.outputPricePerMTok}`;
const tagLabel = m.tags?.length ? ` [${m.tags.join(', ')}]` : '';
return {
label: (
<Text>
{m.label} ·{' '}
<Text dimColor>
{priceLabel} · {m.contextWindow}
{tagLabel}
</Text>
{'\n'}
</Text>
),
value: m.id,
};
}),
{
label: (
<Text>
Custom model
<Text dimColor> · enter model name manually</Text>
{'\n'}
</Text>
),
value: '__custom__',
},
]}
onChange={value => {
logEvent('tengu_china_model_selected', {});
setOAuthStatus({ state: 'china_apikey', provider, mode: accessMode, modelId: value, apiKey: '' });
}}
/>
</Box>
</Box>
);
}
case 'china_apikey': {
const { provider, mode: accessMode, modelId } = oauthStatus;
const [chinaKeyValue, setChinaKeyValue] = useState('');
const [chinaKeyCursor, setChinaKeyCursor] = useState(0);
const [chinaKeyError, setChinaKeyError] = useState<string | null>(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<string, string | undefined> = {
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<typeof updateSettingsForSource>[1] = {
modelType: 'openai',
env: env as unknown as Record<string, string>,
};
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 (
<Box flexDirection="column" gap={1} marginTop={1}>
<Text bold>
{provider.icon} {provider.label} {isCustomModelEntry ? '— Custom Model' : 'API Key'}
</Text>
<Box flexDirection="column" gap={0}>
{isCustomModelEntry ? (
<Text dimColor> Enter any model ID supported by this provider. Browse models: {provider.modelsPage}</Text>
) : (
<>
<Text dimColor> Get your key: {keyPage}</Text>
<Text dimColor>
{' '}
{accessMode === 'coding-plan' ? 'Use your Coding Plan credential here' : provider.freeTier}
</Text>
<Text dimColor> Key format: {keyFormat}</Text>
</>
)}
</Box>
<Box>
<Text>{isCustomModelEntry ? 'Model name: ' : 'API Key: '}</Text>
<TextInput
value={chinaKeyValue}
onChange={v => {
setChinaKeyValue(v);
setChinaKeyError(null);
}}
onSubmit={doChinaSave}
cursorOffset={chinaKeyCursor}
onChangeCursorOffset={setChinaKeyCursor}
columns={useTerminalSize().columns - 12}
mask={isCustomModelEntry ? undefined : '*'}
focus={true}
/>
</Box>
{chinaKeyError ? <Text color="error">{chinaKeyError}</Text> : null}
{isCustomModelEntry && modelSuggestions.length > 0 && (
<Box flexDirection="column" gap={0}>
<Text dimColor>{chinaKeyValue.trim() ? 'Matching models:' : 'Known models:'}</Text>
{modelSuggestions.map(m => (
<Text key={m.id} dimColor>
{' '}
{m.id}{' '}
<Text>
({m.label} {m.provider})
</Text>
</Text>
))}
</Box>
)}
<Text dimColor>
{isCustomModelEntry ? 'Enter to continue · Esc to go back' : 'Enter to confirm · Esc to go back'}
</Text>
</Box>
);
}
case 'platform_setup':
return (
<Box flexDirection="column" gap={1} marginTop={1}>
<Text bold>Using 3rd-party platforms</Text>
<Box flexDirection="column" gap={1}>
<Text>
Claude Code supports Amazon Bedrock, Microsoft Foundry, and Vertex AI. Set the required environment
variables, then restart Claude Code.
</Text>
<Text>
If you are part of an enterprise organization, contact your administrator for setup instructions.
</Text>
<Box flexDirection="column" marginTop={1}>
<Text bold>Documentation:</Text>
<Text>
· Amazon Bedrock:{' '}
<Link url="https://code.claude.com/docs/en/amazon-bedrock">
https://code.claude.com/docs/en/amazon-bedrock
</Link>
</Text>
<Text>
· Microsoft Foundry:{' '}
<Link url="https://code.claude.com/docs/en/microsoft-foundry">
https://code.claude.com/docs/en/microsoft-foundry
</Link>
</Text>
<Text>
· Vertex AI:{' '}
<Link url="https://code.claude.com/docs/en/google-vertex-ai">
https://code.claude.com/docs/en/google-vertex-ai
</Link>
</Text>
</Box>
<Box marginTop={1}>
<Text dimColor>
Press <Text bold>Enter</Text> to go back to login options.
</Text>
</Box>
</Box>
</Box>
);
case 'waiting_for_login':
return (
<Box flexDirection="column" gap={1}>
{forcedMethodMessage && (
<Box>
<Text dimColor>{forcedMethodMessage}</Text>
</Box>
)}
{!showPastePrompt && (
<Box>
<Spinner />
<Text>Opening browser to sign in</Text>
</Box>
)}
{showPastePrompt && (
<Box>
<Text>{PASTE_HERE_MSG}</Text>
<TextInput
value={pastedCode}
onChange={setPastedCode}
onSubmit={(value: string) => handleSubmitCode(value, oauthStatus.url)}
cursorOffset={cursorOffset}
onChangeCursorOffset={setCursorOffset}
columns={textInputColumns}
mask="*"
/>
</Box>
)}
</Box>
);
case 'creating_api_key':
return (
<Box flexDirection="column" gap={1}>
<Box>
<Spinner />
<Text>Creating API key for Claude Code</Text>
</Box>
</Box>
);
case 'about_to_retry':
return (
<Box flexDirection="column" gap={1}>
<Text color="permission">Retrying</Text>
</Box>
);
case 'success':
return (
<Box flexDirection="column">
{mode === 'setup-token' && oauthStatus.token ? null : (
<>
{getOauthAccountInfo()?.emailAddress ? (
<Text dimColor>
Logged in as <Text>{getOauthAccountInfo()?.emailAddress}</Text>
</Text>
) : null}
<Text color="success">
Login successful. Press <Text bold>Enter</Text> to continue
</Text>
</>
)}
</Box>
);
case 'error':
return (
<Box flexDirection="column" gap={1}>
<Text color="error">OAuth error: {oauthStatus.message}</Text>
{oauthStatus.toRetry && (
<Box marginTop={1}>
<Text color="permission">
Press <Text bold>Enter</Text> to retry.
</Text>
</Box>
)}
</Box>
);
default:
return null;
}
}