mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
temp
This commit is contained in:
parent
8b5065c08b
commit
fc12d2b154
6 changed files with 237 additions and 64 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue