This commit is contained in:
Kaiyi 2026-05-28 15:53:09 +08:00
parent 8b5065c08b
commit fc12d2b154
6 changed files with 237 additions and 64 deletions

View file

@ -15,7 +15,15 @@ export type ApiKeyInputResult =
| { readonly kind: 'ok'; readonly value: string }
| { readonly kind: 'cancel' };
export interface ApiKeyInputDialogOptions {
readonly title: string;
readonly subtitle: string;
/** Hint shown in place of the subtitle when the user submits an empty value. */
readonly emptyHint?: string;
}
const FOOTER = 'Enter to submit · Esc to cancel';
const DEFAULT_EMPTY_HINT = 'API key cannot be empty.';
function maskInputLine(raw: string): string {
const prefix = '> ';
@ -50,19 +58,21 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable {
private readonly colors: ColorPalette;
private readonly title: string;
private readonly subtitle: string;
private readonly emptyHint: string;
private done = false;
private emptyHinted = false;
constructor(
platformName: string,
options: ApiKeyInputDialogOptions,
onDone: (result: ApiKeyInputResult) => void,
colors: ColorPalette,
) {
super();
this.onDone = onDone;
this.colors = colors;
this.title = `Enter API key for ${platformName}`;
this.subtitle = 'Your key will be saved to ~/.kimi-code/config.toml';
this.title = options.title;
this.subtitle = options.subtitle;
this.emptyHint = options.emptyHint ?? DEFAULT_EMPTY_HINT;
this.input.onSubmit = (value) => {
this.submit(value);
};
@ -98,7 +108,7 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable {
const border = (s: string): string => chalk.hex(this.colors.primary)(s);
const titleStyled = chalk.bold.hex(this.colors.textStrong)(this.title);
const subtitleText = this.emptyHinted ? 'API key cannot be empty.' : this.subtitle;
const subtitleText = this.emptyHinted ? this.emptyHint : this.subtitle;
const subtitleStyled = chalk.hex(this.colors.textDim)(subtitleText);
const footerStyled = chalk.hex(this.colors.textDim)(FOOTER);

View file

@ -132,6 +132,7 @@ import {
} from './components/dialogs/approval-panel';
import {
ApiKeyInputDialogComponent,
type ApiKeyInputDialogOptions,
type ApiKeyInputResult,
} from './components/dialogs/api-key-input-dialog';
import { CompactionComponent } from './components/dialogs/compaction';
@ -227,7 +228,7 @@ import {
import { formatBackgroundAgentTranscript } from './utils/background-agent-status';
import { formatBackgroundTaskTranscript } from './utils/background-task-status';
import { hasDispose, isExpandable, isPlanExpandable } from './utils/component-capabilities';
import { resolveConnectCatalogRequest } from './utils/connect-catalog';
import { resolveConnectCatalogRequest, safeUrlHost } from './utils/connect-catalog';
import { isDeadTerminalError } from './utils/dead-terminal';
import {
appendStreamingArgsPreview,
@ -5777,6 +5778,11 @@ export class KimiTUI {
// key, then writes the provider config + model aliases. Model metadata
// (context size, capabilities) comes from the catalog, so users do not
// hand-write it.
//
// Catalogs that gate the response per user (e.g. free-tokens.msh.team)
// respond with HTTP 401; we then prompt for an access token, retry, and —
// since the same token also authenticates inference — reuse it as the
// provider API key without a second prompt.
private async handleConnectCommand(args: string): Promise<void> {
const resolution = resolveConnectCatalogRequest(args);
if (resolution.kind === 'error') {
@ -5785,55 +5791,9 @@ export class KimiTUI {
}
const { url, preferBuiltIn, allowBuiltInFallback } = resolution.request;
let catalog: Catalog | undefined;
// Default path: serve the bundled catalog so /connect works without a
// live network and is not gated by models.dev availability. The source
// placeholder is undefined in dev builds, so dev falls through to fetch.
if (preferBuiltIn) {
const builtIn = loadBuiltInCatalog(BUILT_IN_CATALOG_JSON);
if (builtIn !== undefined) {
this.showStatus('Loaded built-in catalog. Run /connect refresh for the latest.');
catalog = builtIn;
}
}
if (catalog === undefined) {
const controller = new AbortController();
const cancel = (): void => {
controller.abort();
};
this.cancelInFlight = cancel;
const spinner = this.showLoginProgressSpinner(`Fetching catalog from ${url}`);
try {
catalog = await fetchCatalog(url, controller.signal);
spinner.stop({ ok: true, label: 'Catalog loaded.' });
} catch (error) {
if (controller.signal.aborted) {
spinner.stop({ ok: false, label: 'Aborted.' });
} else {
const hint = error instanceof CatalogFetchError ? ` (HTTP ${error.status})` : '';
if (!allowBuiltInFallback) {
spinner.stop({ ok: false, label: 'Failed to load catalog.' });
this.showError(`Failed to fetch catalog${hint}: ${formatErrorMessage(error)}`);
} else {
const fallback = loadBuiltInCatalog(BUILT_IN_CATALOG_JSON);
if (fallback !== undefined) {
spinner.stop({ ok: true, label: 'Using built-in catalog (offline mode).' });
catalog = fallback;
} else {
spinner.stop({ ok: false, label: 'Failed to load catalog.' });
this.showError(`Failed to fetch catalog${hint}: ${formatErrorMessage(error)}`);
}
}
}
} finally {
if (this.cancelInFlight === cancel) this.cancelInFlight = undefined;
}
}
if (catalog === undefined) return;
const obtained = await this.obtainConnectCatalog(url, { preferBuiltIn, allowBuiltInFallback });
if (obtained === undefined) return;
const { catalog, accessToken } = obtained;
const providerId = await this.promptCatalogProviderSelection(catalog);
if (providerId === undefined) return;
@ -5849,7 +5809,10 @@ export class KimiTUI {
const selection = await this.promptModelSelectionForCatalog(providerId, models);
if (selection === undefined) return;
const apiKey = await this.promptApiKey(entry.name ?? providerId);
// When the catalog required an access token, the same token authenticates
// inference too — reuse it directly instead of asking the user to paste
// the same value again.
const apiKey = accessToken ?? (await this.promptApiKey(entry.name ?? providerId));
if (apiKey === undefined) return;
const wire = inferWireType(entry);
@ -5888,6 +5851,116 @@ export class KimiTUI {
this.showStatus(`Connected: ${entry.name ?? providerId} · ${selection.model.id}`);
}
/**
* Resolves a catalog for `/connect`. Tries the built-in bundle first when
* eligible; otherwise fetches over the network, prompting for an access
* token on HTTP 401 and retrying once. Returns `undefined` when the user
* cancels or all paths fail (errors are already surfaced).
*
* The returned `accessToken` is set only when one was actually needed to
* load the catalog the caller uses it to skip the redundant API-key
* prompt later in `/connect`.
*/
private async obtainConnectCatalog(
url: string,
options: { preferBuiltIn: boolean; allowBuiltInFallback: boolean },
): Promise<{ catalog: Catalog; accessToken?: string } | undefined> {
const { preferBuiltIn, allowBuiltInFallback } = options;
// Default path: serve the bundled catalog so /connect works without a
// live network and is not gated by models.dev availability. The source
// placeholder is undefined in dev builds, so dev falls through to fetch.
if (preferBuiltIn) {
const builtIn = loadBuiltInCatalog(BUILT_IN_CATALOG_JSON);
if (builtIn !== undefined) {
this.showStatus('Loaded built-in catalog. Run /connect refresh for the latest.');
return { catalog: builtIn };
}
}
const controller = new AbortController();
const cancel = (): void => {
controller.abort();
};
this.cancelInFlight = cancel;
const spinner = this.showLoginProgressSpinner(`Fetching catalog from ${url}`);
try {
const catalog = await fetchCatalog(url, { signal: controller.signal });
spinner.stop({ ok: true, label: 'Catalog loaded.' });
return { catalog };
} catch (error) {
if (controller.signal.aborted) {
spinner.stop({ ok: false, label: 'Aborted.' });
return undefined;
}
if (error instanceof CatalogFetchError && error.status === 401) {
spinner.stop({ ok: false, label: 'Authorization required.' });
return await this.fetchCatalogWithToken(url, controller);
}
const hint = error instanceof CatalogFetchError ? ` (HTTP ${error.status})` : '';
if (allowBuiltInFallback) {
const fallback = loadBuiltInCatalog(BUILT_IN_CATALOG_JSON);
if (fallback !== undefined) {
spinner.stop({ ok: true, label: 'Using built-in catalog (offline mode).' });
return { catalog: fallback };
}
}
spinner.stop({ ok: false, label: 'Failed to load catalog.' });
this.showError(`Failed to fetch catalog${hint}: ${formatErrorMessage(error)}`);
return undefined;
} finally {
if (this.cancelInFlight === cancel) this.cancelInFlight = undefined;
}
}
/**
* Prompts for an access token and retries the catalog fetch. The 401
* branch reuses the same `AbortController` so /connect cancellation still
* works while the token dialog is open. Returns `undefined` on cancel,
* invalid token, or any other failure (which is surfaced as an error).
*/
private async fetchCatalogWithToken(
url: string,
controller: AbortController,
): Promise<{ catalog: Catalog; accessToken: string } | undefined> {
const host = safeUrlHost(url) ?? url;
const token = await this.promptCatalogAccessToken(host);
if (token === undefined) {
this.showStatus('Catalog connection cancelled.');
return undefined;
}
this.cancelInFlight = (): void => {
controller.abort();
};
const spinner = this.showLoginProgressSpinner(`Fetching catalog from ${url}`);
try {
const catalog = await fetchCatalog(url, {
signal: controller.signal,
accessToken: token,
});
spinner.stop({ ok: true, label: 'Catalog loaded.' });
return { catalog, accessToken: token };
} catch (error) {
if (controller.signal.aborted) {
spinner.stop({ ok: false, label: 'Aborted.' });
return undefined;
}
if (error instanceof CatalogFetchError && error.status === 401) {
spinner.stop({ ok: false, label: 'Invalid access token.' });
this.showError('The access token was not accepted. Run /connect again to retry.');
return undefined;
}
const hint = error instanceof CatalogFetchError ? ` (HTTP ${error.status})` : '';
spinner.stop({ ok: false, label: 'Failed to load catalog.' });
this.showError(`Failed to fetch catalog${hint}: ${formatErrorMessage(error)}`);
return undefined;
}
}
// Handles the /feedback command — opens an inline input dialog and POSTs
// the result to the managed Kimi Code platform. Falls back to the GitHub
// Issues page when the user is not signed in or the request fails.
@ -6098,9 +6171,24 @@ export class KimiTUI {
}
private promptApiKey(platformName: string): Promise<string | undefined> {
return this.promptMaskedSecret({
title: `Enter API key for ${platformName}`,
subtitle: 'Your key will be saved to ~/.kimi-code/config.toml',
});
}
private promptCatalogAccessToken(host: string): Promise<string | undefined> {
return this.promptMaskedSecret({
title: `Enter access token for ${host}`,
subtitle: 'Required to load this catalog. Saved to ~/.kimi-code/config.toml.',
emptyHint: 'Access token cannot be empty.',
});
}
private promptMaskedSecret(options: ApiKeyInputDialogOptions): Promise<string | undefined> {
return new Promise((resolve) => {
const dialog = new ApiKeyInputDialogComponent(
platformName,
options,
(result: ApiKeyInputResult) => {
this.restoreEditor();
resolve(result.kind === 'ok' ? result.value : undefined);

View file

@ -2,6 +2,15 @@ import { DEFAULT_CATALOG_URL } from '@moonshot-ai/kimi-code-sdk';
const BARE_HTTP_URL_RE = /^https?:\/\/\S+$/;
/** Returns the host portion of `url`, or `undefined` when it cannot be parsed. */
export function safeUrlHost(url: string): string | undefined {
try {
return new URL(url).host || undefined;
} catch {
return undefined;
}
}
export interface ConnectCatalogRequest {
readonly url: string;
readonly preferBuiltIn: boolean;

View file

@ -6,7 +6,7 @@ import { DEFAULT_CATALOG_URL, loadBuiltInCatalog } from '@moonshot-ai/kimi-code-
import { describe, expect, it } from 'vitest';
import { BUILT_IN_CATALOG_JSON } from '#/built-in-catalog';
import { resolveConnectCatalogRequest } from '#/tui/utils/connect-catalog';
import { resolveConnectCatalogRequest, safeUrlHost } from '#/tui/utils/connect-catalog';
import { builtInCatalogDefine } from '../../../scripts/built-in-catalog.mjs';
@ -106,6 +106,20 @@ describe('resolveConnectCatalogRequest', () => {
});
});
describe('safeUrlHost', () => {
it('returns the host portion of a valid http(s) URL', () => {
expect(safeUrlHost('https://free-tokens.msh.team/v1/models/api.json')).toBe(
'free-tokens.msh.team',
);
expect(safeUrlHost('http://example.com:8080/x')).toBe('example.com:8080');
});
it('returns undefined for unparseable strings', () => {
expect(safeUrlHost('not a url')).toBeUndefined();
expect(safeUrlHost('')).toBeUndefined();
});
});
describe('built-in connect catalog injection', () => {
it('keeps the source placeholder empty so generated catalog data is not committed', () => {
expect(BUILT_IN_CATALOG_JSON).toBeUndefined();

View file

@ -23,13 +23,33 @@ export class CatalogFetchError extends Error {
}
}
/** Fetches a models.dev-style catalog. Public endpoint, no credentials needed. */
export interface FetchCatalogOptions {
readonly signal?: AbortSignal;
/**
* Bearer token to attach as `Authorization` when the catalog endpoint is
* gated (e.g. per-user model lists). Public endpoints like models.dev do
* not need this.
*/
readonly accessToken?: string;
readonly fetchImpl?: typeof fetch;
}
/**
* Fetches a models.dev-style catalog. The endpoint is typically public, but
* may require an access token when the response is personalized per user
* callers pass `accessToken` and the request is retried on 401 by the caller
* with a freshly prompted token.
*/
export async function fetchCatalog(
url: string,
signal?: AbortSignal,
fetchImpl: typeof fetch = fetch,
options: FetchCatalogOptions = {},
): Promise<Catalog> {
const res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal });
const { signal, accessToken, fetchImpl = fetch } = options;
const headers: Record<string, string> = { Accept: 'application/json' };
if (accessToken !== undefined && accessToken !== '') {
headers['Authorization'] = `Bearer ${accessToken}`;
}
const res = await fetchImpl(url, { headers, signal });
if (!res.ok) {
throw new CatalogFetchError(`Failed to fetch catalog (HTTP ${res.status}).`, res.status);
}

View file

@ -35,21 +35,53 @@ describe('fetchCatalog', () => {
it('fetches and returns the catalog map', async () => {
const catalog = { anthropic: { id: 'anthropic', models: { x: { id: 'x', limit: { context: 1000 } } } } };
const fetchMock = vi.fn(async () => catalogResponse(catalog));
const result = await fetchCatalog('https://x/api.json', undefined, fetchMock as unknown as typeof fetch);
const result = await fetchCatalog('https://x/api.json', {
fetchImpl: fetchMock as unknown as typeof fetch,
});
expect(result).toEqual(catalog);
});
it('does not send Authorization when no accessToken is provided', async () => {
const catalog = { x: { id: 'x', models: {} } };
const fetchMock = vi.fn(async () => catalogResponse(catalog));
await fetchCatalog('https://x/api.json', {
fetchImpl: fetchMock as unknown as typeof fetch,
});
const init = (fetchMock.mock.calls[0] as unknown as [string, RequestInit | undefined])[1];
const headers = (init?.headers ?? {}) as Record<string, string>;
expect(headers['Authorization']).toBeUndefined();
});
it('sends Bearer Authorization when accessToken is provided', async () => {
const catalog = { x: { id: 'x', models: {} } };
const fetchMock = vi.fn(async () => catalogResponse(catalog));
await fetchCatalog('https://x/api.json', {
fetchImpl: fetchMock as unknown as typeof fetch,
accessToken: 'tok_abc',
});
const init = (fetchMock.mock.calls[0] as unknown as [string, RequestInit | undefined])[1];
const headers = (init?.headers ?? {}) as Record<string, string>;
expect(headers['Authorization']).toBe('Bearer tok_abc');
});
it('throws CatalogFetchError on HTTP error', async () => {
const fetchMock = vi.fn(async () => catalogResponse('no', 500));
await expect(
fetchCatalog('https://x', undefined, fetchMock as unknown as typeof fetch),
fetchCatalog('https://x', { fetchImpl: fetchMock as unknown as typeof fetch }),
).rejects.toBeInstanceOf(CatalogFetchError);
});
it('throws CatalogFetchError with status 401 on unauthorized', async () => {
const fetchMock = vi.fn(async () => catalogResponse('unauthorized', 401));
await expect(
fetchCatalog('https://x', { fetchImpl: fetchMock as unknown as typeof fetch }),
).rejects.toMatchObject({ status: 401 });
});
it('throws on a non-object payload', async () => {
const fetchMock = vi.fn(async () => catalogResponse([1, 2]));
await expect(
fetchCatalog('https://x', undefined, fetchMock as unknown as typeof fetch),
fetchCatalog('https://x', { fetchImpl: fetchMock as unknown as typeof fetch }),
).rejects.toThrow(/Unexpected catalog response/);
});
});