feat: /login支持codex订阅登录

This commit is contained in:
Bill
2026-05-08 20:35:34 +08:00
parent 73e54d4bbc
commit c7cb3d8f93
17 changed files with 1318 additions and 39 deletions

View File

@@ -9,9 +9,14 @@ import { setClipboard, useTerminalNotification, Box, Link, Text, KeyboardShortcu
import { useKeybinding } from '../keybindings/useKeybinding.js';
import { getSSLErrorHint } from '@ant/model-provider';
import { sendNotification } from '../services/notifier.js';
import {
completeChatGPTDeviceLogin,
requestChatGPTDeviceCode,
type ChatGPTDeviceCode,
} from '../services/api/openai/chatgptAuth.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 { Select } from './CustomSelect/select.js';
@@ -46,6 +51,11 @@ type OAuthStatus =
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;
@@ -445,6 +455,16 @@ function OAuthStatusMessage({
),
value: 'openai_chat_api',
},
{
label: (
<Text>
ChatGPT account with subscription ·{' '}
<Text dimColor>Plus, Pro, Business, Edu, or Enterprise</Text>
{'\n'}
</Text>
),
value: 'chatgpt_subscription',
},
{
label: (
<Text>
@@ -515,6 +535,12 @@ function OAuthStatusMessage({
opusModel: process.env.OPENAI_DEFAULT_OPUS_MODEL ?? '',
activeField: 'base_url',
});
} 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({
@@ -807,7 +833,9 @@ function OAuthStatusMessage({
const doOpenAISave = useCallback(() => {
const finalVals = { ...openaiDisplayValues, [activeField]: openaiInputValue };
const env: Record<string, string> = {};
const env: Record<string, string | undefined> = {
OPENAI_AUTH_MODE: undefined,
};
// Validate base_url if provided
if (finalVals.base_url) {
@@ -836,10 +864,11 @@ function OAuthStatusMessage({
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);
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',
@@ -855,7 +884,13 @@ function OAuthStatusMessage({
},
});
} else {
for (const [k, v] of Object.entries(env)) process.env[k] = v;
for (const [k, v] of Object.entries(env)) {
if (v === undefined) {
delete process.env[k];
} else {
process.env[k] = v;
}
}
setOAuthStatus({ state: 'success' });
void onDone();
}
@@ -953,6 +988,93 @@ function OAuthStatusMessage({
);
}
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;
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'];

View File

@@ -22,6 +22,7 @@ import {
getDefaultEffortForModel,
modelSupportsEffort,
modelSupportsMaxEffort,
modelSupportsXhighEffort,
resolvePickerEffortPersistence,
toPersistableEffort,
} from '../utils/effort.js';
@@ -146,11 +147,19 @@ export function ModelPicker({
focusedValue !== NO_PREFERENCE &&
marked1MValues.has(focusedValue.replace(/\[1m\]/i, ''));
const focusedSupportsEffort = focusedModel ? modelSupportsEffort(focusedModel) : false;
const focusedSupportsXhigh = focusedModel ? modelSupportsXhighEffort(focusedModel) : false;
const focusedSupportsMax = focusedModel ? modelSupportsMaxEffort(focusedModel) : false;
const focusedDefaultEffort = getDefaultEffortLevelForOption(focusedValue);
// Clamp display when 'max' is selected but the focused model doesn't support it.
// Clamp display when selected effort isn't supported by the focused model.
// resolveAppliedEffort() does the same downgrade at API-send time.
const displayEffort = effort === 'max' && !focusedSupportsMax ? 'high' : effort;
const displayEffort =
effort === 'max' && !focusedSupportsMax
? focusedSupportsXhigh
? 'xhigh'
: 'high'
: effort === 'xhigh' && !focusedSupportsXhigh
? 'high'
: effort;
const handleFocus = useCallback(
(value: string) => {
@@ -166,10 +175,22 @@ export function ModelPicker({
const handleCycleEffort = useCallback(
(direction: 'left' | 'right') => {
if (!focusedSupportsEffort) return;
setEffort(prev => cycleEffortLevel(prev ?? focusedDefaultEffort, direction, focusedSupportsMax));
setEffort(prev =>
cycleEffortLevel(
prev ?? focusedDefaultEffort,
direction,
focusedSupportsXhigh,
focusedSupportsMax,
),
);
setHasToggledEffort(true);
},
[focusedSupportsEffort, focusedSupportsMax, focusedDefaultEffort],
[
focusedSupportsEffort,
focusedSupportsXhigh,
focusedSupportsMax,
focusedDefaultEffort,
],
);
useKeybindings(
@@ -333,8 +354,19 @@ function EffortLevelIndicator({ effort }: { effort?: EffortLevel }): React.React
return <Text color={effort ? 'claude' : 'subtle'}>{effortLevelToSymbol(effort ?? 'low')}</Text>;
}
function cycleEffortLevel(current: EffortLevel, direction: 'left' | 'right', includeMax: boolean): EffortLevel {
const levels: EffortLevel[] = includeMax ? ['low', 'medium', 'high', 'max'] : ['low', 'medium', 'high'];
function cycleEffortLevel(
current: EffortLevel,
direction: 'left' | 'right',
includeXhigh: boolean,
includeMax: boolean,
): EffortLevel {
const levels: EffortLevel[] = [
'low',
'medium',
'high',
...(includeXhigh ? (['xhigh'] as const) : []),
...(includeMax ? (['max'] as const) : []),
];
// If the current level isn't in the cycle (e.g. 'max' after switching to a
// non-Opus model), clamp to 'high'.
const idx = levels.indexOf(current);