Skip to content

Commit df8c4f4

Browse files
Merge pull request #438 from q1352013520/feature/codex-subscription
feat: /login支持codex订阅登录
2 parents 02dd796 + b52c10d commit df8c4f4

17 files changed

Lines changed: 1307 additions & 39 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { inferLegacyCompanionBones } from '../companion.js'
3+
4+
describe('inferLegacyCompanionBones', () => {
5+
test('infers species and rarity from legacy seedless companion text', () => {
6+
expect(
7+
inferLegacyCompanionBones({
8+
name: 'Biscuit',
9+
personality: 'A common mushroom of few words.',
10+
}),
11+
).toEqual({
12+
species: 'mushroom',
13+
rarity: 'common',
14+
})
15+
})
16+
17+
test('does not override seeded companions', () => {
18+
expect(
19+
inferLegacyCompanionBones({
20+
name: 'Spore',
21+
personality: 'A common mushroom of few words.',
22+
seed: 'rehatch-1',
23+
}),
24+
).toEqual({})
25+
})
26+
})

src/buddy/companion.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getGlobalConfig } from '../utils/config.js'
22
import {
33
type Companion,
44
type CompanionBones,
5+
type CompanionSoul,
56
EYES,
67
HATS,
78
RARITIES,
@@ -125,12 +126,36 @@ export function companionUserId(): string {
125126
return config.oauthAccount?.accountUuid ?? config.userID ?? 'anon'
126127
}
127128

129+
const WORD_BOUNDARY = '[^a-z0-9]+'
130+
131+
function hasWord(text: string, word: string): boolean {
132+
return new RegExp(`(^|${WORD_BOUNDARY})${word}($|${WORD_BOUNDARY})`).test(
133+
text,
134+
)
135+
}
136+
137+
export function inferLegacyCompanionBones(
138+
stored: CompanionSoul,
139+
): Partial<Pick<CompanionBones, 'species' | 'rarity'>> {
140+
if (stored.seed) return {}
141+
const text = `${stored.name} ${stored.personality}`.toLowerCase()
142+
const inferred: Partial<Pick<CompanionBones, 'species' | 'rarity'>> = {}
143+
const species = SPECIES.find(species => hasWord(text, species))
144+
const rarity = RARITIES.find(rarity => hasWord(text, rarity))
145+
if (species) inferred.species = species
146+
if (rarity) inferred.rarity = rarity
147+
return inferred
148+
}
149+
128150
// Regenerate bones from seed or userId, merge with stored soul.
129151
export function getCompanion(): Companion | undefined {
130152
const stored = getGlobalConfig().companion
131153
if (!stored) return undefined
132154
const seed = stored.seed ?? companionUserId()
133155
const { bones } = rollWithSeed(seed)
134-
// bones last so stale bones fields in old-format configs get overridden
135-
return { ...stored, ...bones }
156+
const legacyBones = inferLegacyCompanionBones(stored)
157+
// Seeded companions use regenerated bones. Legacy seedless companions may
158+
// have species/rarity embedded in their generated soul text; keep that
159+
// visible identity coherent when the userId-derived roll drifts.
160+
return { ...stored, ...bones, ...legacyBones }
136161
}

src/commands/effort/effort.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, arg
155155

156156
if (COMMON_HELP_ARGS.includes(args)) {
157157
onDone(
158-
'Usage: /effort [low|medium|high|max|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- max: Maximum capability with deepest reasoning (Opus 4.6/4.7, DeepSeek V4 Pro)\n- auto: Use the default effort level for your model',
158+
'Usage: /effort [low|medium|high|xhigh|max|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- xhigh: Extra high reasoning for supported models, including ChatGPT Codex models\n- max: Maximum capability with deepest reasoning where supported (Opus 4.6/4.7, DeepSeek V4 Pro); maps to xhigh for ChatGPT Codex models\n- auto: Use the default effort level for your model',
159159
);
160160
return;
161161
}

src/commands/logout/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { isEnvTruthy } from '../../utils/envUtils.js'
44
export default {
55
type: 'local-jsx',
66
name: 'logout',
7-
description: 'Sign out from your Anthropic account',
7+
description: 'Sign out from your configured account',
88
isEnabled: () => !isEnvTruthy(process.env.DISABLE_LOGOUT_COMMAND),
99
load: () => import('./logout.js'),
1010
} satisfies Command

src/commands/logout/logout.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import { getGroveNoticeConfig, getGroveSettings } from '../../services/api/grove
66
import { clearPolicyLimitsCache } from '../../services/policyLimits/index.js';
77
// flushTelemetry is loaded lazily to avoid pulling in ~1.1MB of OpenTelemetry at startup
88
import { clearRemoteManagedSettingsCache } from '../../services/remoteManagedSettings/index.js';
9+
import { removeChatGPTAuth } from '../../services/api/openai/chatgptAuth.js';
910
import { getClaudeAIOAuthTokens, removeApiKey } from '../../utils/auth.js';
1011
import { clearBetasCaches } from '../../utils/betas.js';
1112
import { saveGlobalConfig } from '../../utils/config.js';
1213
import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js';
1314
import { getSecureStorage } from '../../utils/secureStorage/index.js';
15+
import { getSettingsForSource, updateSettingsForSource } from '../../utils/settings/settings.js';
1416
import { clearToolSchemaCache } from '../../utils/toolSchemaCache.js';
1517
import { resetUserCache } from '../../utils/user.js';
1618

@@ -20,6 +22,8 @@ export async function performLogout({ clearOnboarding = false }): Promise<void>
2022
await flushTelemetry();
2123

2224
await removeApiKey();
25+
await removeChatGPTAuth();
26+
clearChatGPTSettingsAuthMode();
2327

2428
// Wipe all secure storage data on logout
2529
const secureStorage = getSecureStorage();
@@ -44,6 +48,22 @@ export async function performLogout({ clearOnboarding = false }): Promise<void>
4448
});
4549
}
4650

51+
function clearChatGPTSettingsAuthMode(): void {
52+
delete process.env.OPENAI_AUTH_MODE;
53+
const userSettings = getSettingsForSource('userSettings') ?? {};
54+
const env = userSettings.env ?? {};
55+
const hasOpenAICompatibleConfig =
56+
Boolean(env.OPENAI_API_KEY ?? process.env.OPENAI_API_KEY) &&
57+
Boolean(env.OPENAI_BASE_URL ?? process.env.OPENAI_BASE_URL);
58+
const settingsUpdate: Parameters<typeof updateSettingsForSource>[1] = {
59+
...(userSettings.modelType === 'openai' && !hasOpenAICompatibleConfig ? { modelType: undefined } : {}),
60+
env: {
61+
OPENAI_AUTH_MODE: undefined,
62+
} as unknown as Record<string, string>,
63+
};
64+
updateSettingsForSource('userSettings', settingsUpdate);
65+
}
66+
4767
// clearing anything memoized that must be invalidated when user/session/auth changes
4868
export async function clearAuthRelatedCaches(): Promise<void> {
4969
// Clear the OAuth token cache
@@ -70,7 +90,7 @@ export async function clearAuthRelatedCaches(): Promise<void> {
7090
export async function call(): Promise<React.ReactNode> {
7191
await performLogout({ clearOnboarding: true });
7292

73-
const message = <Text>Successfully logged out from your Anthropic account.</Text>;
93+
const message = <Text>Successfully logged out.</Text>;
7494

7595
setTimeout(() => {
7696
gracefulShutdownSync(0, 'logout');

src/commands/provider.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,10 @@ const call: LocalCommandCall = async (args, _context) => {
8181
// Check env vars when switching to openai (including settings.env)
8282
if (arg === 'openai') {
8383
const mergedEnv = getMergedEnv()
84+
const hasChatGPTAuth = mergedEnv.OPENAI_AUTH_MODE === 'chatgpt'
8485
const hasKey = !!mergedEnv.OPENAI_API_KEY
8586
const hasUrl = !!mergedEnv.OPENAI_BASE_URL
86-
if (!hasKey || !hasUrl) {
87+
if (!hasChatGPTAuth && (!hasKey || !hasUrl)) {
8788
updateSettingsForSource('userSettings', { modelType: 'openai' })
8889
const missing = []
8990
if (!hasKey) missing.push('OPENAI_API_KEY')

src/components/ConsoleOAuthFlow.tsx

Lines changed: 128 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,14 @@ import { setClipboard, useTerminalNotification, Box, Link, Text, KeyboardShortcu
99
import { useKeybinding } from '../keybindings/useKeybinding.js';
1010
import { getSSLErrorHint } from '@ant/model-provider';
1111
import { sendNotification } from '../services/notifier.js';
12+
import {
13+
completeChatGPTDeviceLogin,
14+
requestChatGPTDeviceCode,
15+
type ChatGPTDeviceCode,
16+
} from '../services/api/openai/chatgptAuth.js';
1217
import { OAuthService } from '../services/oauth/index.js';
1318
import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js';
14-
19+
import { openBrowser } from '../utils/browser.js';
1520
import { logError } from '../utils/log.js';
1621
import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js';
1722
import { Select } from './CustomSelect/select.js';
@@ -46,6 +51,11 @@ type OAuthStatus =
4651
opusModel: string;
4752
activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model';
4853
} // OpenAI Chat Completions API platform
54+
| {
55+
state: 'chatgpt_subscription';
56+
phase: 'requesting' | 'waiting';
57+
deviceCode?: ChatGPTDeviceCode;
58+
} // ChatGPT account subscription via Codex OAuth device flow
4959
| {
5060
state: 'gemini_api';
5161
baseUrl: string;
@@ -445,6 +455,15 @@ function OAuthStatusMessage({
445455
),
446456
value: 'openai_chat_api',
447457
},
458+
{
459+
label: (
460+
<Text>
461+
ChatGPT account with subscription · <Text dimColor>Plus, Pro, Business, Edu, or Enterprise</Text>
462+
{'\n'}
463+
</Text>
464+
),
465+
value: 'chatgpt_subscription',
466+
},
448467
{
449468
label: (
450469
<Text>
@@ -515,6 +534,12 @@ function OAuthStatusMessage({
515534
opusModel: process.env.OPENAI_DEFAULT_OPUS_MODEL ?? '',
516535
activeField: 'base_url',
517536
});
537+
} else if (value === 'chatgpt_subscription') {
538+
logEvent('tengu_chatgpt_subscription_selected', {});
539+
setOAuthStatus({
540+
state: 'chatgpt_subscription',
541+
phase: 'requesting',
542+
});
518543
} else if (value === 'gemini_api') {
519544
logEvent('tengu_gemini_api_selected', {});
520545
setOAuthStatus({
@@ -807,7 +832,9 @@ function OAuthStatusMessage({
807832

808833
const doOpenAISave = useCallback(() => {
809834
const finalVals = { ...openaiDisplayValues, [activeField]: openaiInputValue };
810-
const env: Record<string, string> = {};
835+
const env: Record<string, string | undefined> = {
836+
OPENAI_AUTH_MODE: undefined,
837+
};
811838

812839
// Validate base_url if provided
813840
if (finalVals.base_url) {
@@ -836,10 +863,11 @@ function OAuthStatusMessage({
836863
if (finalVals.haiku_model) env.OPENAI_DEFAULT_HAIKU_MODEL = finalVals.haiku_model;
837864
if (finalVals.sonnet_model) env.OPENAI_DEFAULT_SONNET_MODEL = finalVals.sonnet_model;
838865
if (finalVals.opus_model) env.OPENAI_DEFAULT_OPUS_MODEL = finalVals.opus_model;
839-
const { error } = updateSettingsForSource('userSettings', {
840-
modelType: 'openai' as any,
841-
env,
842-
} as any);
866+
const settingsUpdate: Parameters<typeof updateSettingsForSource>[1] = {
867+
modelType: 'openai',
868+
env: env as unknown as Record<string, string>,
869+
};
870+
const { error } = updateSettingsForSource('userSettings', settingsUpdate);
843871
if (error) {
844872
setOAuthStatus({
845873
state: 'error',
@@ -855,7 +883,13 @@ function OAuthStatusMessage({
855883
},
856884
});
857885
} else {
858-
for (const [k, v] of Object.entries(env)) process.env[k] = v;
886+
for (const [k, v] of Object.entries(env)) {
887+
if (v === undefined) {
888+
delete process.env[k];
889+
} else {
890+
process.env[k] = v;
891+
}
892+
}
859893
setOAuthStatus({ state: 'success' });
860894
void onDone();
861895
}
@@ -953,6 +987,93 @@ function OAuthStatusMessage({
953987
);
954988
}
955989

990+
case 'chatgpt_subscription': {
991+
const status = oauthStatus as {
992+
state: 'chatgpt_subscription';
993+
phase: 'requesting' | 'waiting';
994+
deviceCode?: ChatGPTDeviceCode;
995+
};
996+
const startedRef = useRef(false);
997+
998+
useEffect(() => {
999+
if (startedRef.current) return;
1000+
startedRef.current = true;
1001+
let cancelled = false;
1002+
const controller = new AbortController();
1003+
async function runLogin() {
1004+
try {
1005+
const deviceCode = await requestChatGPTDeviceCode();
1006+
if (cancelled) return;
1007+
setOAuthStatus({
1008+
state: 'chatgpt_subscription',
1009+
phase: 'waiting',
1010+
deviceCode,
1011+
});
1012+
void openBrowser(deviceCode.verificationUrl);
1013+
await completeChatGPTDeviceLogin(deviceCode, controller.signal);
1014+
if (cancelled) return;
1015+
const env: Record<string, string> = {
1016+
OPENAI_AUTH_MODE: 'chatgpt',
1017+
};
1018+
const settingsUpdate: Parameters<typeof updateSettingsForSource>[1] = {
1019+
modelType: 'openai',
1020+
env,
1021+
};
1022+
const { error } = updateSettingsForSource('userSettings', settingsUpdate);
1023+
if (error) {
1024+
throw new Error('Failed to save settings. Please try again.');
1025+
}
1026+
for (const [k, v] of Object.entries(env)) process.env[k] = v;
1027+
setOAuthStatus({ state: 'success' });
1028+
void onDone();
1029+
} catch (err) {
1030+
if (cancelled) return;
1031+
setOAuthStatus({
1032+
state: 'error',
1033+
message: (err as Error).message,
1034+
toRetry: {
1035+
state: 'chatgpt_subscription',
1036+
phase: 'requesting',
1037+
},
1038+
});
1039+
}
1040+
}
1041+
void runLogin();
1042+
return () => {
1043+
cancelled = true;
1044+
controller.abort();
1045+
};
1046+
}, [setOAuthStatus, onDone]);
1047+
1048+
return (
1049+
<Box flexDirection="column" gap={1}>
1050+
<Text bold>ChatGPT Account Setup</Text>
1051+
{status.phase === 'requesting' && (
1052+
<Box>
1053+
<Spinner />
1054+
<Text>Requesting sign-in code…</Text>
1055+
</Box>
1056+
)}
1057+
{status.phase === 'waiting' && status.deviceCode && (
1058+
<Box flexDirection="column" gap={1}>
1059+
<Text>Open this link and sign in with your ChatGPT account:</Text>
1060+
<Link url={status.deviceCode.verificationUrl}>
1061+
<Text dimColor>{status.deviceCode.verificationUrl}</Text>
1062+
</Link>
1063+
<Text>
1064+
Enter code: <Text bold>{status.deviceCode.userCode}</Text>
1065+
</Text>
1066+
<Box>
1067+
<Spinner />
1068+
<Text>Waiting for ChatGPT authorization…</Text>
1069+
</Box>
1070+
</Box>
1071+
)}
1072+
<Text dimColor>Esc to go back. Device codes expire after 15 minutes.</Text>
1073+
</Box>
1074+
);
1075+
}
1076+
9561077
case 'gemini_api': {
9571078
type GeminiField = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model';
9581079
const GEMINI_FIELDS: GeminiField[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model'];

0 commit comments

Comments
 (0)