Skip to content

Commit 69284bf

Browse files
committed
feat: add kimi and minimax oauth login
1 parent 7838e5e commit 69284bf

16 files changed

Lines changed: 1610 additions & 15 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": minor
3+
---
4+
5+
Add OAuth sign-in for Kimi For Coding and MiniMax (global and China) to the login flow.

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

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
buildPlatformOptions,
33
catalogModelToAlias,
44
catalogProviderIdFromPlatformValue,
5+
isOAuthPlatformId,
56
DEFAULT_CATALOG_URL,
67
resolveCatalogImport,
78
type Catalog,
@@ -11,6 +12,11 @@ import {
1112
type ThinkingEffort,
1213
} from '@pymodel/pythinker-code-sdk';
1314
import {
15+
KIMI_CODING_PROVIDER_ID,
16+
KIMI_OAUTH_PLATFORM_ID,
17+
MINIMAX_OAUTH_PLATFORM_ID_CN,
18+
MINIMAX_OAUTH_PLATFORM_ID_GLOBAL,
19+
minimaxCodingProviderId,
1420
OPENAI_CODEX_OAUTH_PLATFORM_ID,
1521
capabilitiesForModel,
1622
OPENAI_CODEX_PROVIDER_ID,
@@ -37,6 +43,22 @@ import { fetchCatalogOrBuiltIn } from '#/utils/catalog-fetch';
3743
import { formatErrorMessage } from '#/tui/utils/event-payload';
3844
import type { SlashCommandHost } from './dispatch';
3945

46+
/** Maps an OAuth-type platform picker value to the provider id it writes into config. */
47+
function oauthPlatformConfigProviderId(platformValue: string): string | undefined {
48+
switch (platformValue) {
49+
case OPENAI_CODEX_OAUTH_PLATFORM_ID:
50+
return OPENAI_CODEX_PROVIDER_ID;
51+
case KIMI_OAUTH_PLATFORM_ID:
52+
return KIMI_CODING_PROVIDER_ID;
53+
case MINIMAX_OAUTH_PLATFORM_ID_GLOBAL:
54+
return minimaxCodingProviderId('global');
55+
case MINIMAX_OAUTH_PLATFORM_ID_CN:
56+
return minimaxCodingProviderId('cn');
57+
default:
58+
return undefined;
59+
}
60+
}
61+
4062
export async function promptPlatformSelection(
4163
host: SlashCommandHost,
4264
): Promise<PlatformSelection | undefined> {
@@ -48,15 +70,10 @@ export async function promptPlatformSelection(
4870

4971
const config = await host.harness.getConfig({ reload: true });
5072
const providers = buildPlatformOptions(catalog)
51-
.filter((option) =>
52-
method === 'oauth'
53-
? option.value === OPENAI_CODEX_OAUTH_PLATFORM_ID
54-
: option.value !== OPENAI_CODEX_OAUTH_PLATFORM_ID,
55-
)
73+
.filter((option) => (method === 'oauth' ? isOAuthPlatformId(option.value) : !isOAuthPlatformId(option.value)))
5674
.map((option): PlatformSelectorProvider => {
5775
const providerId = catalogProviderIdFromPlatformValue(option.value) ?? option.value;
58-
const configProviderId =
59-
option.value === OPENAI_CODEX_OAUTH_PLATFORM_ID ? OPENAI_CODEX_PROVIDER_ID : providerId;
76+
const configProviderId = oauthPlatformConfigProviderId(option.value) ?? providerId;
6077
const configured = config.providers[configProviderId];
6178
return {
6279
value: option.value,

packages/node-sdk/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,12 @@ export { buildSkillSlashCommands, isUserActivatableSkill } from '#/skill-command
5050
export type { SkillSlashCommand, SkillSlashCommands } from '#/skill-commands';
5151

5252
export { formatErrorMessage, formatErrorPayload } from '#/error-format';
53-
export { buildPlatformOptions, resolvePlatformOption, type PlatformOption } from '#/login/platform-options';
53+
export {
54+
buildPlatformOptions,
55+
isOAuthPlatformId,
56+
resolvePlatformOption,
57+
type PlatformOption,
58+
} from '#/login/platform-options';
5459
export {
5560
CATALOG_PLATFORM_VALUE_PREFIX,
5661
catalogProviderIdFromPlatformValue,

packages/node-sdk/src/login/flows.ts

Lines changed: 179 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,34 @@
11
import {
2+
applyKimiOAuthConfig,
3+
applyMiniMaxOAuthConfig,
24
applyOpenAICodexOAuthConfig,
35
applyOpenPlatformConfig,
6+
fetchKimiCodingModels,
47
fetchOpenAICodexModels,
58
fetchOpenPlatformModels,
69
filterModelsByPrefix,
710
getOpenPlatformById,
11+
miniMaxCodingModels,
12+
minimaxCodingProviderId,
13+
minimaxRegionLabel,
14+
KIMI_CODING_PROVIDER_ID,
15+
KIMI_OAUTH_PLATFORM_ID,
16+
MINIMAX_OAUTH_PLATFORM_ID_CN,
17+
MINIMAX_OAUTH_PLATFORM_ID_GLOBAL,
818
OPENAI_CODEX_OAUTH_PLATFORM_ID,
919
OPENAI_CODEX_PROVIDER_ID,
20+
OAuthAccessDeniedError,
1021
OpenAICodexApiError,
1122
OpenPlatformApiError,
12-
OAuthAccessDeniedError,
23+
runKimiOAuthFlow,
24+
runMiniMaxOAuthFlow,
1325
runOpenAICodexOAuthFlow,
14-
type ProviderModelInfo,
15-
type PythinkerConfigShape,
26+
type DeviceCodeInfo,
27+
type MiniMaxRegion,
1628
type OpenAICodexModelInfo,
1729
type OpenPlatformDefinition,
30+
type ProviderModelInfo,
31+
type PythinkerConfigShape,
1832
} from '@pymodel/pythinker-code-oauth';
1933

2034
import {
@@ -29,7 +43,7 @@ import type { PythinkerConfig } from '#/types';
2943

3044
import { formatErrorMessage } from '../error-format';
3145
import { catalogProviderIdFromPlatformValue } from './platform-values';
32-
import type { LoginUi } from './types';
46+
import type { LoginProgressSpinnerHandle, LoginUi } from './types';
3347

3448
export async function runLogin(ui: LoginUi): Promise<boolean> {
3549
const selection = await ui.promptPlatformSelection();
@@ -43,6 +57,15 @@ export async function runLogin(ui: LoginUi): Promise<boolean> {
4357
if (platformId === OPENAI_CODEX_OAUTH_PLATFORM_ID) {
4458
return handleOpenAICodexOAuthLogin(ui);
4559
}
60+
if (platformId === KIMI_OAUTH_PLATFORM_ID) {
61+
return handleKimiOAuthLogin(ui);
62+
}
63+
if (platformId === MINIMAX_OAUTH_PLATFORM_ID_GLOBAL) {
64+
return handleMiniMaxOAuthLogin(ui, 'global');
65+
}
66+
if (platformId === MINIMAX_OAUTH_PLATFORM_ID_CN) {
67+
return handleMiniMaxOAuthLogin(ui, 'cn');
68+
}
4669
const platform = getOpenPlatformById(platformId);
4770
return platform === undefined ? false : handleOpenPlatformLogin(ui, platform);
4871
}
@@ -262,6 +285,158 @@ async function handleOpenAICodexOAuthLogin(ui: LoginUi): Promise<boolean> {
262285
}
263286
}
264287

288+
async function handleKimiOAuthLogin(ui: LoginUi): Promise<boolean> {
289+
const controller = new AbortController();
290+
let committing = false;
291+
const cancelLogin = (): void => {
292+
if (!committing) controller.abort();
293+
};
294+
ui.cancelInFlight = cancelLogin;
295+
try {
296+
let spinner: LoginProgressSpinnerHandle | undefined;
297+
let tokens;
298+
try {
299+
tokens = await runKimiOAuthFlow({
300+
signal: controller.signal,
301+
onCodeReady: (info: DeviceCodeInfo) => {
302+
ui.openBrowser(info.verificationUriComplete ?? info.verificationUri);
303+
spinner = ui.showLoginProgressSpinner(
304+
`Waiting for authorization — open ${info.verificationUri} and enter code ${info.userCode}`,
305+
);
306+
},
307+
});
308+
} catch (error) {
309+
spinner?.stop({ ok: false, label: 'Sign-in failed.' });
310+
if (controller.signal.aborted) return false;
311+
if (error instanceof OAuthAccessDeniedError) {
312+
ui.showError(`Kimi login cancelled: ${formatErrorMessage(error)}`);
313+
return false;
314+
}
315+
ui.showError(`Kimi login failed: ${formatErrorMessage(error)}`);
316+
return false;
317+
}
318+
spinner?.stop({ ok: true, label: 'Authorized.' });
319+
320+
let models: ProviderModelInfo[];
321+
try {
322+
models = await fetchKimiCodingModels(tokens.accessToken, tokens.deviceId, fetch, controller.signal);
323+
} catch (error) {
324+
if (controller.signal.aborted) return false;
325+
ui.showError(`Failed to list Kimi For Coding models: ${formatErrorMessage(error)}`);
326+
return false;
327+
}
328+
if (models.length === 0) {
329+
ui.showError('No models available for Kimi For Coding.');
330+
return false;
331+
}
332+
333+
const picked = await ui.promptModelSelectionForOpenPlatform(models, {
334+
id: KIMI_CODING_PROVIDER_ID,
335+
name: 'Kimi For Coding',
336+
});
337+
if (picked === undefined) return false;
338+
const selectedModel = models.find((model) => model.id === picked.model.id);
339+
if (selectedModel === undefined) return false;
340+
341+
controller.signal.throwIfAborted();
342+
const current = await ui.harness.getConfig({ reload: true });
343+
controller.signal.throwIfAborted();
344+
const next = cloneConfig(current);
345+
applyKimiOAuthConfig(next as PythinkerConfigShape, {
346+
accessToken: tokens.accessToken,
347+
refreshToken: tokens.refreshToken,
348+
deviceId: tokens.deviceId,
349+
models,
350+
selectedModel,
351+
thinking: picked.effort !== 'off',
352+
effort: picked.effort === 'off' || picked.effort === 'on' ? undefined : picked.effort,
353+
});
354+
committing = true;
355+
await ui.harness.replaceConfigSections({
356+
providers: next.providers,
357+
models: next.models,
358+
defaultModel: next.defaultModel,
359+
thinking: next.thinking,
360+
});
361+
await ui.refreshConfigAfterLogin();
362+
ui.track('login', { provider: KIMI_CODING_PROVIDER_ID, method: 'oauth' });
363+
ui.showStatus(`Setup complete: Kimi For Coding · ${selectedModel.id}`);
364+
return true;
365+
} finally {
366+
if (ui.cancelInFlight === cancelLogin) ui.cancelInFlight = undefined;
367+
}
368+
}
369+
370+
async function handleMiniMaxOAuthLogin(ui: LoginUi, region: MiniMaxRegion): Promise<boolean> {
371+
const controller = new AbortController();
372+
let committing = false;
373+
const cancelLogin = (): void => {
374+
if (!committing) controller.abort();
375+
};
376+
ui.cancelInFlight = cancelLogin;
377+
const regionLabel = minimaxRegionLabel(region);
378+
try {
379+
let spinner: LoginProgressSpinnerHandle | undefined;
380+
let tokens;
381+
try {
382+
tokens = await runMiniMaxOAuthFlow(region, {
383+
signal: controller.signal,
384+
onCodeReady: (info: DeviceCodeInfo) => {
385+
ui.openBrowser(info.verificationUriComplete ?? info.verificationUri);
386+
spinner = ui.showLoginProgressSpinner(
387+
`Waiting for authorization — open ${info.verificationUri} and enter code ${info.userCode}`,
388+
);
389+
},
390+
});
391+
} catch (error) {
392+
spinner?.stop({ ok: false, label: 'Sign-in failed.' });
393+
if (controller.signal.aborted) return false;
394+
if (error instanceof OAuthAccessDeniedError) {
395+
ui.showError(`${regionLabel} login cancelled: ${formatErrorMessage(error)}`);
396+
return false;
397+
}
398+
ui.showError(`${regionLabel} login failed: ${formatErrorMessage(error)}`);
399+
return false;
400+
}
401+
spinner?.stop({ ok: true, label: 'Authorized.' });
402+
403+
const providerId = minimaxCodingProviderId(region);
404+
const models = [...miniMaxCodingModels()];
405+
const picked = await ui.promptModelSelectionForOpenPlatform(models, {
406+
id: providerId,
407+
name: regionLabel,
408+
});
409+
if (picked === undefined) return false;
410+
const selectedModel = models.find((model) => model.id === picked.model.id);
411+
if (selectedModel === undefined) return false;
412+
413+
controller.signal.throwIfAborted();
414+
const current = await ui.harness.getConfig({ reload: true });
415+
controller.signal.throwIfAborted();
416+
const next = cloneConfig(current);
417+
applyMiniMaxOAuthConfig(next as PythinkerConfigShape, region, {
418+
accessToken: tokens.accessToken,
419+
refreshToken: tokens.refreshToken,
420+
selectedModel,
421+
thinking: picked.effort !== 'off',
422+
effort: picked.effort === 'off' || picked.effort === 'on' ? undefined : picked.effort,
423+
});
424+
committing = true;
425+
await ui.harness.replaceConfigSections({
426+
providers: next.providers,
427+
models: next.models,
428+
defaultModel: next.defaultModel,
429+
thinking: next.thinking,
430+
});
431+
await ui.refreshConfigAfterLogin();
432+
ui.track('login', { provider: providerId, method: 'oauth' });
433+
ui.showStatus(`Setup complete: ${regionLabel} · ${selectedModel.id}`);
434+
return true;
435+
} finally {
436+
if (ui.cancelInFlight === cancelLogin) ui.cancelInFlight = undefined;
437+
}
438+
}
439+
265440
function cloneConfig(config: PythinkerConfig): PythinkerConfig {
266441
return {
267442
...config,

packages/node-sdk/src/login/platform-options.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,28 @@
1-
import { OPENAI_CODEX_OAUTH_PLATFORM_ID, OPEN_PLATFORMS } from '@pymodel/pythinker-code-oauth';
1+
import {
2+
KIMI_OAUTH_PLATFORM_ID,
3+
MINIMAX_OAUTH_PLATFORM_ID_CN,
4+
MINIMAX_OAUTH_PLATFORM_ID_GLOBAL,
5+
minimaxRegionLabel,
6+
OPENAI_CODEX_OAUTH_PLATFORM_ID,
7+
OPEN_PLATFORMS,
8+
} from '@pymodel/pythinker-code-oauth';
29

310
import { resolveCatalogImport, type Catalog, type CatalogProviderEntry } from '#/catalog';
411

512
import { CATALOG_PLATFORM_VALUE_PREFIX } from './platform-values';
613

14+
/** OAuth-type platform ids offered directly by pythinker-code (no catalog entry). */
15+
const OAUTH_PLATFORM_IDS: ReadonlySet<string> = new Set([
16+
OPENAI_CODEX_OAUTH_PLATFORM_ID,
17+
KIMI_OAUTH_PLATFORM_ID,
18+
MINIMAX_OAUTH_PLATFORM_ID_GLOBAL,
19+
MINIMAX_OAUTH_PLATFORM_ID_CN,
20+
]);
21+
22+
export function isOAuthPlatformId(value: string): boolean {
23+
return OAUTH_PLATFORM_IDS.has(value);
24+
}
25+
726
export interface PlatformOption {
827
readonly value: string;
928
readonly label: string;
@@ -40,8 +59,28 @@ export function buildPlatformOptions(catalog: Catalog): readonly PlatformOption[
4059
label: 'OpenAI Codex (OAuth)',
4160
description: 'OAuth',
4261
},
62+
{
63+
value: KIMI_OAUTH_PLATFORM_ID,
64+
label: 'Kimi For Coding (OAuth)',
65+
description: 'OAuth',
66+
},
67+
{
68+
value: MINIMAX_OAUTH_PLATFORM_ID_GLOBAL,
69+
label: minimaxRegionLabel('global'),
70+
description: 'OAuth',
71+
},
72+
{
73+
value: MINIMAX_OAUTH_PLATFORM_ID_CN,
74+
label: minimaxRegionLabel('cn'),
75+
description: 'OAuth',
76+
},
4377
];
44-
const seen = new Set<string>([OPENAI_CODEX_OAUTH_PLATFORM_ID]);
78+
const seen = new Set<string>([
79+
OPENAI_CODEX_OAUTH_PLATFORM_ID,
80+
KIMI_OAUTH_PLATFORM_ID,
81+
MINIMAX_OAUTH_PLATFORM_ID_GLOBAL,
82+
MINIMAX_OAUTH_PLATFORM_ID_CN,
83+
]);
4584

4685
for (const featured of FEATURED_CATALOG_PROVIDERS) {
4786
const entry = catalog[featured.id];

0 commit comments

Comments
 (0)