diff --git a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts index 8296cb0a2..e30107e0b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts @@ -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); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 0e71ef4ee..c5377a525 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -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 { 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 { + 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 { + 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 { return new Promise((resolve) => { const dialog = new ApiKeyInputDialogComponent( - platformName, + options, (result: ApiKeyInputResult) => { this.restoreEditor(); resolve(result.kind === 'ok' ? result.value : undefined); diff --git a/apps/kimi-code/src/tui/utils/connect-catalog.ts b/apps/kimi-code/src/tui/utils/connect-catalog.ts index dfd86bda5..9df30784d 100644 --- a/apps/kimi-code/src/tui/utils/connect-catalog.ts +++ b/apps/kimi-code/src/tui/utils/connect-catalog.ts @@ -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; diff --git a/apps/kimi-code/test/tui/utils/connect-catalog.test.ts b/apps/kimi-code/test/tui/utils/connect-catalog.test.ts index 98ece0d29..2c5a148d1 100644 --- a/apps/kimi-code/test/tui/utils/connect-catalog.test.ts +++ b/apps/kimi-code/test/tui/utils/connect-catalog.test.ts @@ -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(); diff --git a/packages/node-sdk/src/catalog.ts b/packages/node-sdk/src/catalog.ts index 86687a960..cbe72a2aa 100644 --- a/packages/node-sdk/src/catalog.ts +++ b/packages/node-sdk/src/catalog.ts @@ -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 { - const res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal }); + const { signal, accessToken, fetchImpl = fetch } = options; + const headers: Record = { 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); } diff --git a/packages/node-sdk/test/catalog.test.ts b/packages/node-sdk/test/catalog.test.ts index 42dca028e..572dfb981 100644 --- a/packages/node-sdk/test/catalog.test.ts +++ b/packages/node-sdk/test/catalog.test.ts @@ -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; + 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; + 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/); }); });