Skip to content

Commit c61ffef

Browse files
committed
feat(auth): 支持 ChatGPT Plus/Pro OAuth 登录
1 parent 66f611a commit c61ffef

67 files changed

Lines changed: 3231 additions & 260 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": minor
3+
---
4+
5+
Add ChatGPT Plus/Pro OAuth as a login provider. Run /login and choose OpenAI ChatGPT Plus/Pro to set it up.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
web: Add ChatGPT Plus/Pro sign-in from the login gate and account settings, with independent logout for each OAuth account.

apps/kimi-code/src/tui/commands/auth.ts

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,25 @@
1+
import { join } from 'node:path';
2+
13
import {
4+
applyOpenAICodexConfig,
25
applyOpenPlatformConfig,
6+
FileTokenStorage,
7+
fetchOpenAICodexModels,
38
fetchOpenPlatformModels,
49
filterModelsByPrefix,
510
getOpenPlatformById,
11+
loginOpenAICodexDeviceCode,
12+
OPENAI_CODEX_MODELS,
13+
OPENAI_CODEX_OAUTH_KEY,
14+
OPENAI_CODEX_PROVIDER_NAME,
615
OpenPlatformApiError,
16+
resolveKimiTokenStorageName,
717
type ManagedKimiCodeModelInfo,
818
type ManagedKimiConfigShape,
19+
type OpenAICodexModelInfo,
920
type OpenPlatformDefinition,
1021
} from '@moonshot-ai/kimi-code-oauth';
11-
import { log } from '@moonshot-ai/kimi-code-sdk';
22+
import { log, type ModelAlias } from '@moonshot-ai/kimi-code-sdk';
1223

1324
import type { ChoiceOption } from '../components/dialogs/choice-picker';
1425
import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '../constant/kimi-tui';
@@ -19,6 +30,7 @@ import {
1930
promptLogoutProviderSelection,
2031
promptModelSelectionForOpenPlatform,
2132
promptPlatformSelection,
33+
runModelSelector,
2234
} from './prompts';
2335
import type { SlashCommandHost } from './dispatch';
2436

@@ -34,12 +46,131 @@ export async function handleLoginCommand(host: SlashCommandHost): Promise<void>
3446
await handleKimiCodeOAuthLogin(host);
3547
return;
3648
}
49+
if (platformId === OPENAI_CODEX_PROVIDER_NAME) {
50+
await handleOpenAICodexOAuthLogin(host);
51+
return;
52+
}
3753

3854
const platform = getOpenPlatformById(platformId);
3955
if (platform === undefined) return;
4056
await handleOpenPlatformLogin(host, platform);
4157
}
4258

59+
async function handleOpenAICodexOAuthLogin(host: SlashCommandHost): Promise<void> {
60+
let spinner: LoginProgressSpinnerHandle | undefined;
61+
const tokenStorage = new FileTokenStorage(join(host.harness.homeDir, 'credentials'));
62+
const tokenStorageName = resolveKimiTokenStorageName({
63+
providerName: OPENAI_CODEX_PROVIDER_NAME,
64+
oauthKey: OPENAI_CODEX_OAUTH_KEY,
65+
});
66+
const controller = new AbortController();
67+
const cancelLogin = (): void => {
68+
controller.abort();
69+
};
70+
host.cancelInFlight = cancelLogin;
71+
let openAICodexModels: readonly OpenAICodexModelInfo[] = OPENAI_CODEX_MODELS;
72+
try {
73+
const token = await loginOpenAICodexDeviceCode({
74+
signal: controller.signal,
75+
onDeviceCode: (data) => {
76+
spinner = host.showLoginAuthorizationPrompt(
77+
{
78+
userCode: data.userCode,
79+
deviceCode: '',
80+
verificationUri: data.verificationUri,
81+
verificationUriComplete: data.verificationUri,
82+
expiresIn: data.expiresIn,
83+
interval: data.interval,
84+
},
85+
{ title: 'Sign in to ChatGPT' },
86+
);
87+
},
88+
});
89+
await tokenStorage.save(tokenStorageName, token);
90+
try {
91+
openAICodexModels = await fetchOpenAICodexModels(token.accessToken, {
92+
signal: controller.signal,
93+
});
94+
} catch (error) {
95+
log.warn('failed to fetch OpenAI Codex models; using built-in fallback', {
96+
providerName: OPENAI_CODEX_PROVIDER_NAME,
97+
sessionId: host.session?.id,
98+
error,
99+
});
100+
}
101+
spinner?.stop({ ok: true, label: 'Logged in.' });
102+
spinner = undefined;
103+
} catch (error) {
104+
const cancelled = controller.signal.aborted;
105+
spinner?.stop({
106+
ok: false,
107+
label: cancelled ? 'Login cancelled.' : 'Login failed.',
108+
});
109+
spinner = undefined;
110+
if (!cancelled) {
111+
log.warn('login failed', {
112+
providerName: OPENAI_CODEX_PROVIDER_NAME,
113+
sessionId: host.session?.id,
114+
error,
115+
});
116+
host.showError(`Login failed: ${formatErrorMessage(error)}`);
117+
}
118+
return;
119+
} finally {
120+
if (host.cancelInFlight === cancelLogin) {
121+
host.cancelInFlight = undefined;
122+
}
123+
}
124+
125+
const modelDict: Record<string, ModelAlias> = {};
126+
for (const model of openAICodexModels) {
127+
const supportEfforts = [...(model.supportEfforts ?? ['low', 'medium', 'high', 'xhigh'])];
128+
modelDict[`${OPENAI_CODEX_PROVIDER_NAME}/${model.id}`] = {
129+
provider: OPENAI_CODEX_PROVIDER_NAME,
130+
model: model.id,
131+
maxContextSize: model.contextLength,
132+
capabilities: model.supportsImageIn
133+
? ['tool_use', 'thinking', 'image_in']
134+
: ['tool_use', 'thinking'],
135+
displayName: model.displayName,
136+
supportEfforts,
137+
defaultEffort: model.defaultEffort ?? (supportEfforts.includes('medium') ? 'medium' : supportEfforts[0]),
138+
};
139+
}
140+
const selection = await runModelSelector(host, modelDict);
141+
if (selection === undefined) {
142+
await tokenStorage.remove(tokenStorageName);
143+
return;
144+
}
145+
146+
const selectedModelId = selection.alias.slice(`${OPENAI_CODEX_PROVIDER_NAME}/`.length);
147+
const existingConfig = await host.harness.getConfig();
148+
if (existingConfig.providers[OPENAI_CODEX_PROVIDER_NAME] !== undefined) {
149+
await host.harness.removeProvider(OPENAI_CODEX_PROVIDER_NAME);
150+
}
151+
152+
const config = await host.harness.getConfig();
153+
applyOpenAICodexConfig(config as ManagedKimiConfigShape, {
154+
selectedModelId,
155+
thinking: selection.thinking !== 'off',
156+
effort:
157+
selection.thinking !== 'off' && selection.thinking !== 'on'
158+
? selection.thinking
159+
: undefined,
160+
models: openAICodexModels,
161+
});
162+
await host.harness.setConfig({
163+
providers: config.providers,
164+
models: config.models,
165+
defaultModel: config.defaultModel,
166+
thinking: config.thinking,
167+
});
168+
169+
await host.authFlow.refreshConfigAfterLogin();
170+
host.track('login', { provider: OPENAI_CODEX_PROVIDER_NAME, method: 'oauth' });
171+
host.showStatus(`Setup complete: OpenAI ChatGPT Plus/Pro · ${selectedModelId}`);
172+
}
173+
43174
async function handleKimiCodeOAuthLogin(host: SlashCommandHost): Promise<void> {
44175
const status = await host.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME);
45176
const alreadyLoggedIn = status.providers.some(
@@ -222,6 +353,14 @@ export async function handleLogoutCommand(host: SlashCommandHost): Promise<void>
222353
if (target === DEFAULT_OAUTH_PROVIDER_NAME) {
223354
await host.harness.auth.logout(DEFAULT_OAUTH_PROVIDER_NAME);
224355
} else {
356+
if (target === OPENAI_CODEX_PROVIDER_NAME) {
357+
await new FileTokenStorage(join(host.harness.homeDir, 'credentials')).remove(
358+
resolveKimiTokenStorageName({
359+
providerName: OPENAI_CODEX_PROVIDER_NAME,
360+
oauthKey: OPENAI_CODEX_OAUTH_KEY,
361+
}),
362+
);
363+
}
225364
await host.harness.removeProvider(target);
226365
}
227366

apps/kimi-code/src/tui/commands/dispatch.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,10 @@ export interface SlashCommandHost {
127127

128128
// UI
129129
showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle;
130-
showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle;
130+
showLoginAuthorizationPrompt(
131+
auth: DeviceAuthorization,
132+
options?: { readonly title?: string | undefined },
133+
): LoginProgressSpinnerHandle;
131134
showProgressSpinner(label: string): LoginProgressSpinnerHandle;
132135

133136
// Theme

apps/kimi-code/src/tui/commands/prompts.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ import type {
1515
import { ApiKeyInputDialogComponent, type ApiKeyInputResult } from '../components/dialogs/api-key-input-dialog';
1616
import { ChoicePickerComponent, type ChoiceOption } from '../components/dialogs/choice-picker';
1717
import { FeedbackInputDialogComponent, type FeedbackInputDialogResult } from '../components/dialogs/feedback-input-dialog';
18-
import { ModelSelectorComponent } from '../components/dialogs/model-selector';
18+
import {
19+
defaultThinkingEffortFor,
20+
ModelSelectorComponent,
21+
} from '../components/dialogs/model-selector';
1922
import { PlatformSelectorComponent } from '../components/dialogs/platform-selector';
2023
import type { SlashCommandHost } from './dispatch';
2124

@@ -230,12 +233,12 @@ export function runModelSelector(
230233
): Promise<{ alias: string; thinking: ThinkingEffort } | undefined> {
231234
return new Promise((resolve) => {
232235
const firstAlias = Object.keys(modelDict)[0] ?? '';
233-
const caps = modelDict[firstAlias]?.capabilities ?? [];
234-
const initialThinking = caps.includes('always_thinking') || caps.includes('thinking');
236+
const firstModel = modelDict[firstAlias];
235237
const selector = new ModelSelectorComponent({
236238
models: modelDict,
237239
currentValue: firstAlias,
238-
currentThinkingEffort: initialThinking ? 'on' : 'off',
240+
currentThinkingEffort:
241+
firstModel === undefined ? 'off' : defaultThinkingEffortFor(firstModel),
239242
searchable: true,
240243
onSelect: ({ alias, thinking }) => {
241244
host.restoreEditor();

apps/kimi-code/src/tui/components/dialogs/model-selector.ts

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -132,11 +132,15 @@ export function effortLabel(effort: string): string {
132132
* middle `support_efforts` entry, else `'on'` for boolean models, `'off'` when
133133
* thinking is unsupported.
134134
*/
135-
function defaultThinkingEffortFor(model: ModelAlias): ThinkingEffort {
136-
if (thinkingAvailability(model) === 'unsupported') return 'off';
137-
const efforts = effortsOf(model);
135+
export function defaultThinkingEffortFor(model: ModelAlias): ThinkingEffort {
136+
const effective = effectiveModelAlias(model);
137+
if (thinkingAvailability(effective) === 'unsupported') return 'off';
138+
const efforts = effortsOf(effective);
138139
if (efforts.length > 0) {
139-
return model.defaultEffort ?? efforts[Math.floor(efforts.length / 2)]!;
140+
if (effective.defaultEffort !== undefined && efforts.includes(effective.defaultEffort)) {
141+
return effective.defaultEffort;
142+
}
143+
return efforts[Math.floor(efforts.length / 2)]!;
140144
}
141145
return 'on';
142146
}
@@ -191,15 +195,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
191195
const override = this.thinkingOverrides.get(choice.alias);
192196
if (override !== undefined) return override;
193197
if (choice.alias === this.opts.currentValue) return this.opts.currentThinkingEffort;
194-
const efforts = effortsOf(choice.model);
195-
if (efforts.length > 0) {
196-
// A model with support_efforts but no default_effort defaults to the
197-
// middle entry of its supported efforts.
198-
const def = choice.model.defaultEffort ?? efforts[Math.floor(efforts.length / 2)];
199-
if (def !== undefined && efforts.includes(def)) return def;
200-
return efforts[0]!;
201-
}
202-
return thinkingAvailability(choice.model) !== 'unsupported' ? 'on' : 'off';
198+
return defaultThinkingEffortFor(choice.model);
203199
}
204200

205201
/** Draft coerced onto the model's segment list so rendering/selection never

apps/kimi-code/src/tui/components/dialogs/platform-selector.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { OPEN_PLATFORMS } from '@moonshot-ai/kimi-code-oauth';
1+
import { OPENAI_CODEX_PROVIDER_NAME, OPEN_PLATFORMS } from '@moonshot-ai/kimi-code-oauth';
22

33
import { ChoicePickerComponent, type ChoiceOption } from './choice-picker';
44

55
const PLATFORM_OPTIONS: readonly ChoiceOption[] = [
66
{ value: 'kimi-code', label: 'Kimi Code (OAuth)' },
7+
{ value: OPENAI_CODEX_PROVIDER_NAME, label: 'OpenAI ChatGPT Plus/Pro (OAuth)' },
78
...OPEN_PLATFORMS.map((platform) => ({ value: platform.id, label: platform.name })),
89
];
910

apps/kimi-code/src/tui/kimi-tui.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2342,11 +2342,14 @@ export class KimiTUI {
23422342
};
23432343
}
23442344

2345-
showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle {
2345+
showLoginAuthorizationPrompt(
2346+
auth: DeviceAuthorization,
2347+
options: { readonly title?: string | undefined } = {},
2348+
): LoginProgressSpinnerHandle {
23462349
openUrl(auth.verificationUriComplete);
23472350
this.state.transcriptContainer.addChild(
23482351
new DeviceCodeBoxComponent({
2349-
title: 'Sign in to Kimi Code',
2352+
title: options.title ?? 'Sign in to Kimi Code',
23502353
url: auth.verificationUriComplete,
23512354
code: auth.userCode,
23522355
hint: 'Press Ctrl-C to cancel',

apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5020,6 +5020,26 @@ command = "vim"
50205020
await expect(selection).resolves.toBeUndefined();
50215021
});
50225022

5023+
it('selects the declared default effort when the shared model selector opens', async () => {
5024+
const { driver } = await makeDriver();
5025+
const selection = runModelSelector(driver as any, {
5026+
codex: {
5027+
provider: 'openai-codex',
5028+
model: 'gpt-test',
5029+
maxContextSize: 353_000,
5030+
capabilities: ['thinking'],
5031+
supportEfforts: ['low', 'medium', 'high'],
5032+
defaultEffort: 'high',
5033+
},
5034+
});
5035+
5036+
const picker = driver.state.editorContainer.children[0];
5037+
expect(picker).toBeInstanceOf(ModelSelectorComponent);
5038+
(picker as ModelSelectorComponent).handleInput('\r');
5039+
5040+
await expect(selection).resolves.toEqual({ alias: 'codex', thinking: 'high' });
5041+
});
5042+
50235043
it('deletes Kitty inline images when /new clears the transcript', async () => {
50245044
setCapabilities({ images: 'kitty', trueColor: true, hyperlinks: true });
50255045
const { driver, harness } = await makeDriver(makeSession({ id: 'ses-1' }));

0 commit comments

Comments
 (0)