From 3c5dee8836ac823fce01707f60b9c095a963060e Mon Sep 17 00:00:00 2001 From: 7Sageer <12210216@mail.sustech.edu.cn> Date: Tue, 2 Jun 2026 14:58:47 +0800 Subject: [PATCH] feat(cli): add `kimi provider` subcommand for non-interactive provider management (#313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): add `kimi provider` subcommand Add a non-interactive equivalent of the TUI `/provider` command: - `kimi provider add --api-key ` imports every provider in a custom api.json registry, persisting `source` so the next TUI launch refreshes the model list automatically. - `kimi provider remove ` deletes a provider and its model aliases. - `kimi provider list [--json]` prints configured providers with model counts and source labels. - `kimi provider catalog list [providerId] [--filter] [--url] [--json]` browses the public models.dev catalog. - `kimi provider catalog add --api-key [--default-model]` imports a known provider straight from the catalog. All actions reuse `fetchCustomRegistry`, `applyCustomRegistryProvider`, `fetchCatalog`, and `applyCatalogProvider` from the existing oauth/SDK helpers. * fix(cli): satisfy oxlint rules in provider subcommand - Use `Array#toSorted()` instead of `Array#sort()` to avoid mutating arrays returned from `Object.keys()` / `Object.entries()`. - Drop redundant boolean-literal comparisons on `model.capability.*` fields (already typed as `boolean | undefined`). - Remove the unnecessary `source as Record` assertion in `providerSourceLabel` — `ProviderConfig.source` is already typed that way in the schema. - Drop the empty-object fallback in `{ ...(config.models ?? {}) }` inside the test harness. * fix(cli): address review findings on provider subcommand P1 — `provider add`: `harness.removeProvider` re-reads the config from disk (see `agent-core/src/rpc/core-impl.ts removeKimiProvider`), so calling it mid-loop discarded providers we had already applied in memory but not yet persisted. Importing a registry that added a new provider then replaced an existing one silently lost the new one. Drop every stale id up front in a single batch, then apply each entry against the resulting fresh config. P2 — `catalog add`: `applyCatalogProvider` always writes `defaultThinking`. Hardcoding `false` would silently disable thinking for thinking-capable models when the user had it on. Thread the prior `defaultThinking` through. P2 — `catalog add`: `removeProvider` clears `defaultModel` when it pointed at one of the provider's aliases, so capturing `previousDefaultModel` AFTER the removal yielded `undefined`. Capture both `defaultModel` and `defaultThinking` BEFORE the removal so re-importing a configured provider (e.g. to rotate the api key) preserves the user's chosen default. Tests: - `makeHarness` now models the on-disk semantics of `removeProvider` (clears `defaultModel` when an alias matches, returns fresh disk view), so behavior that depended on the buggy in-memory mock is exercised honestly. - Three new regression tests, each verified to fail against the pre-fix handler. * fix(cli): address follow-up review on catalog default semantics Two more findings on `catalog add`: P2 — `default_thinking` fallback to `false` was wrong even after the previous fix. `resolveThinkingLevel` (agent-core/.../thinking.ts:23) treats `defaultThinking === false` as an explicit "off" request and silently disables thinking before per-model defaults kick in. A first-time `kimi provider catalog add anthropic --default-model claude-opus-4-7` was therefore still persisting `default_thinking = false` for thinking-capable models. The handler now always restores the previous `defaultThinking` (including `undefined`) — the only way to let the runtime resolver pick the per-model default. P2 — Restoring `default_model` was unconditional, even when the refreshed catalog no longer ships that model. `applyCatalogProvider` drops the old aliases and only populates the current catalog, so restoring an alias the catalog no longer contains would point `default_model` at a non-existent entry and break the next session. The handler now checks whether the alias still resolves and clears it otherwise. Test harness: - The fake `setConfig` now mirrors the real `mergeConfigPatch` semantics (deep-merge with `undefined` keys skipped), so tests can honestly assert that `setConfig({defaultModel: undefined})` does NOT wipe a key from disk — only `removeProvider` can. Two new regression tests, each verified to fail against the pre-fix handler. --------- Co-authored-by: 7Sageer <158020838+7Sageer@users.noreply.github.com> --- .changeset/cli-provider-subcommand.md | 5 + apps/kimi-code/src/cli/commands.ts | 2 + apps/kimi-code/src/cli/sub/provider.ts | 524 +++++++++++++ apps/kimi-code/test/cli/options.test.ts | 2 +- apps/kimi-code/test/cli/provider.test.ts | 891 +++++++++++++++++++++++ docs/en/configuration/providers.md | 2 + docs/en/reference/kimi-command.md | 87 +++ docs/zh/configuration/providers.md | 2 + docs/zh/reference/kimi-command.md | 87 +++ 9 files changed, 1601 insertions(+), 1 deletion(-) create mode 100644 .changeset/cli-provider-subcommand.md create mode 100644 apps/kimi-code/src/cli/sub/provider.ts create mode 100644 apps/kimi-code/test/cli/provider.test.ts diff --git a/.changeset/cli-provider-subcommand.md b/.changeset/cli-provider-subcommand.md new file mode 100644 index 000000000..d3eb22922 --- /dev/null +++ b/.changeset/cli-provider-subcommand.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add `kimi provider` CLI subcommand with `add`, `remove`, `list`, and `catalog list` / `catalog add` actions, so providers from a custom registry (api.json) or the public models.dev catalog can be imported and managed without launching the TUI. diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 2cdd4bd08..500489841 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -6,6 +6,7 @@ import { registerMigrateCommand } from '#/migration/index'; import type { CLIOptions } from './options'; import { registerExportCommand } from './sub/export'; +import { registerProviderCommand } from './sub/provider'; export type MainCommandHandler = (opts: CLIOptions) => void; export type MigrateCommandHandler = () => void; @@ -74,6 +75,7 @@ export function createProgram( .option('--plan', 'Start in plan mode.', false); registerExportCommand(program); + registerProviderCommand(program); registerMigrateCommand(program, onMigrate); program diff --git a/apps/kimi-code/src/cli/sub/provider.ts b/apps/kimi-code/src/cli/sub/provider.ts new file mode 100644 index 000000000..bac1964e8 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/provider.ts @@ -0,0 +1,524 @@ +/** + * `kimi provider` sub-command — non-interactive provider management. + * + * Mirrors the TUI `/provider` flow (apps/kimi-code/src/tui/commands/provider.ts) + * for the custom-registry path so users can import an api.json document, drop + * a provider, or inspect what is configured without launching the TUI. + * + * `add` writes the same `source = { kind: 'apiJson', url, apiKey }` blob the + * TUI does; the next launch's `refreshAllProviderModels` + * (apps/kimi-code/src/tui/utils/refresh-providers.ts) groups by `{url, apiKey}` + * and re-fetches the model list, so periodic refresh is automatic. + */ + +import { + applyCustomRegistryProvider, + CustomRegistryApiError, + fetchCustomRegistry, + type CustomRegistrySource, + type ManagedKimiConfigShape, +} from '@moonshot-ai/kimi-code-oauth'; +import { + applyCatalogProvider, + catalogBaseUrl, + catalogProviderModels, + CatalogFetchError, + DEFAULT_CATALOG_URL, + fetchCatalog, + inferWireType, + KimiHarness, + type Catalog, + type CatalogProviderEntry, + type KimiConfig, +} from '@moonshot-ai/kimi-code-sdk'; +import type { Command } from 'commander'; + +import { createKimiCodeHostIdentity } from '#/cli/version'; + +interface WritableLike { + write(chunk: string): boolean; +} + +export interface ProviderDeps { + readonly getHarness: () => KimiHarness; + readonly stdout: WritableLike; + readonly stderr: WritableLike; + readonly env: NodeJS.ProcessEnv; + readonly exit: (code: number) => never; +} + +interface AddOptions { + readonly apiKey?: string; +} + +interface ListOptions { + readonly json: boolean; +} + +interface CatalogListOptions { + readonly json: boolean; + readonly filter?: string; + readonly url?: string; +} + +interface CatalogAddOptions { + readonly apiKey?: string; + readonly defaultModel?: string; + readonly url?: string; +} + +export async function handleProviderAdd( + deps: ProviderDeps, + url: string, + opts: AddOptions, +): Promise { + const apiKey = resolveApiKey(opts.apiKey, deps.env); + if (apiKey === undefined) { + deps.stderr.write( + 'Missing API key. Pass --api-key or set KIMI_REGISTRY_API_KEY.\n', + ); + deps.exit(1); + } + + const trimmedUrl = url.trim(); + if (trimmedUrl.length === 0) { + deps.stderr.write('Registry URL is required.\n'); + deps.exit(1); + } + + const source: CustomRegistrySource = { + kind: 'apiJson', + url: trimmedUrl, + apiKey, + }; + + const harness = deps.getHarness(); + await harness.ensureConfigFile(); + + let entries: Awaited>; + try { + entries = await fetchCustomRegistry(source); + } catch (error) { + const suffix = error instanceof CustomRegistryApiError ? ` (HTTP ${String(error.status)})` : ''; + deps.stderr.write(`Failed to fetch registry${suffix}: ${errorMessage(error)}\n`); + deps.exit(1); + } + + const entryList = Object.values(entries); + if (entryList.length === 0) { + deps.stderr.write(`Registry at ${trimmedUrl} contained no usable providers.\n`); + deps.exit(1); + } + + // `harness.removeProvider` reloads the config from disk on each call (see + // `core-impl.ts removeKimiProvider`), so calling it inside the apply loop + // would discard providers we already applied in memory but have not yet + // persisted. Drop every stale id up front in a single batch instead, then + // apply against the resulting fresh config. + let config = await harness.getConfig(); + const staleIds = entryList + .filter((entry) => config.providers[entry.id] !== undefined) + .map((entry) => entry.id); + for (const id of staleIds) { + config = await harness.removeProvider(id); + } + + const addedProviderIds: string[] = []; + let modelCount = 0; + for (const entry of entryList) { + applyCustomRegistryProvider(asManaged(config), entry, source); + addedProviderIds.push(entry.id); + modelCount += Object.keys(entry.models).length; + } + + await harness.setConfig({ + providers: config.providers, + models: config.models, + }); + + deps.stdout.write( + `Imported ${String(addedProviderIds.length)} provider${addedProviderIds.length === 1 ? '' : 's'} ` + + `(${String(modelCount)} model${modelCount === 1 ? '' : 's'}) from ${trimmedUrl}:\n`, + ); + for (const id of addedProviderIds) { + deps.stdout.write(` - ${id}\n`); + } +} + +export async function handleProviderRemove( + deps: ProviderDeps, + providerId: string, +): Promise { + const harness = deps.getHarness(); + await harness.ensureConfigFile(); + const config = await harness.getConfig(); + if (config.providers[providerId] === undefined) { + deps.stderr.write(`Provider "${providerId}" not found.\n`); + deps.exit(1); + } + await harness.removeProvider(providerId); + deps.stdout.write(`Removed provider "${providerId}".\n`); +} + +export async function handleProviderList( + deps: ProviderDeps, + opts: ListOptions, +): Promise { + const harness = deps.getHarness(); + await harness.ensureConfigFile(); + const config = await harness.getConfig(); + + if (opts.json) { + deps.stdout.write( + `${JSON.stringify({ providers: config.providers, models: config.models ?? {} }, null, 2)}\n`, + ); + return; + } + + const modelsByProvider = new Map(); + for (const [alias, model] of Object.entries(config.models ?? {})) { + const list = modelsByProvider.get(model.provider) ?? []; + list.push(alias); + modelsByProvider.set(model.provider, list); + } + + const providerIds = Object.keys(config.providers).toSorted(); + if (providerIds.length === 0) { + deps.stdout.write('No providers configured.\n'); + return; + } + + for (const id of providerIds) { + const provider = config.providers[id]!; + const aliases = modelsByProvider.get(id) ?? []; + const sourceLabel = providerSourceLabel(provider); + deps.stdout.write( + `${id} type=${provider.type} models=${String(aliases.length)} source=${sourceLabel}\n`, + ); + } + if (config.defaultModel !== undefined) { + deps.stdout.write(`\nDefault model: ${config.defaultModel}\n`); + } +} + +/** + * Fetches the models.dev-style public catalog and lists providers, or — when + * `providerId` is given — drills into one provider and lists its models. This + * mirrors the discovery half of the TUI "Known third-party provider" flow. + */ +export async function handleCatalogList( + deps: ProviderDeps, + providerId: string | undefined, + opts: CatalogListOptions, +): Promise { + const url = opts.url ?? DEFAULT_CATALOG_URL; + const catalog = await loadCatalogOrExit(deps, url); + + if (providerId !== undefined) { + const entry = catalog[providerId]; + if (entry === undefined) { + deps.stderr.write(`Provider "${providerId}" not found in catalog at ${url}.\n`); + deps.exit(1); + } + const models = catalogProviderModels(entry); + if (opts.json) { + deps.stdout.write( + `${JSON.stringify({ providerId, name: entry.name ?? providerId, models }, null, 2)}\n`, + ); + return; + } + if (models.length === 0) { + deps.stdout.write(`Provider "${providerId}" lists no usable models in this catalog.\n`); + return; + } + deps.stdout.write(`${entry.name ?? providerId} (${providerId})\n`); + for (const model of models) { + const cap: string[] = []; + if (model.capability.tool_use) cap.push('tool_use'); + if (model.capability.thinking) cap.push('thinking'); + if (model.capability.image_in) cap.push('image_in'); + const ctx = + typeof model.capability.max_context_tokens === 'number' + ? String(model.capability.max_context_tokens) + : '?'; + const capLabel = cap.length > 0 ? ` [${cap.join(',')}]` : ''; + deps.stdout.write(` ${model.id} ctx=${ctx}${capLabel}\n`); + } + return; + } + + const filter = opts.filter?.toLowerCase(); + const entries = Object.entries(catalog) + .filter(([id, entry]) => { + if (filter === undefined) return true; + const haystack = `${id} ${entry.name ?? ''}`.toLowerCase(); + return haystack.includes(filter); + }) + .toSorted(([a], [b]) => a.localeCompare(b)); + + if (opts.json) { + const out: Record = {}; + for (const [id, entry] of entries) out[id] = entry; + deps.stdout.write(`${JSON.stringify(out, null, 2)}\n`); + return; + } + + if (entries.length === 0) { + if (filter !== undefined) { + deps.stdout.write(`No providers in catalog match "${filter}".\n`); + } else { + deps.stdout.write('Catalog is empty.\n'); + } + return; + } + + for (const [id, entry] of entries) { + const modelCount = entry.models === undefined ? 0 : Object.keys(entry.models).length; + const wire = inferWireType(entry) ?? '?'; + deps.stdout.write( + `${id} wire=${wire} models=${String(modelCount)} ${entry.name ?? ''}\n`, + ); + } +} + +/** + * Imports a known provider from the models.dev catalog by id. Unlike + * `provider add` (which expects a custom api.json), this command relies on + * the catalog's normalized metadata to fill in context limits and capabilities. + */ +export async function handleCatalogAdd( + deps: ProviderDeps, + providerId: string, + opts: CatalogAddOptions, +): Promise { + const apiKey = resolveApiKey(opts.apiKey, deps.env); + if (apiKey === undefined) { + deps.stderr.write( + 'Missing API key. Pass --api-key or set KIMI_REGISTRY_API_KEY.\n', + ); + deps.exit(1); + } + + const url = opts.url ?? DEFAULT_CATALOG_URL; + const catalog = await loadCatalogOrExit(deps, url); + + const entry = catalog[providerId]; + if (entry === undefined) { + deps.stderr.write(`Provider "${providerId}" not found in catalog at ${url}.\n`); + deps.exit(1); + } + + const wire = inferWireType(entry); + if (wire === undefined) { + deps.stderr.write(`Provider "${providerId}" has an unsupported wire type in the catalog.\n`); + deps.exit(1); + } + + const models = catalogProviderModels(entry); + if (models.length === 0) { + deps.stderr.write(`Provider "${providerId}" lists no usable models in this catalog.\n`); + deps.exit(1); + } + + if (opts.defaultModel !== undefined && !models.some((m) => m.id === opts.defaultModel)) { + deps.stderr.write( + `Model "${opts.defaultModel}" is not in provider "${providerId}". Run "kimi provider catalog list ${providerId}" to see available ids.\n`, + ); + deps.exit(1); + } + + const harness = deps.getHarness(); + await harness.ensureConfigFile(); + + let config = await harness.getConfig(); + + // Capture defaults BEFORE `removeProvider`, because that call clears + // `defaultModel` when it points at one of this provider's aliases (see + // `core-impl.ts removeKimiProvider`). Without this, re-importing an + // already-configured provider would lose the user's previously-set default + // even when `--default-model` is not supplied. + const previousDefaultModel = config.defaultModel; + const previousDefaultThinking = config.defaultThinking; + + if (config.providers[providerId] !== undefined) { + config = await harness.removeProvider(providerId); + } + + const baseUrl = catalogBaseUrl(entry, wire); + // `applyCatalogProvider` always overwrites both `defaultModel` and + // `defaultThinking`. The values we pass here are temporary; we restore + // a consistent state in the post-apply block below. + applyCatalogProvider(config, { + providerId, + wire, + ...(baseUrl === undefined ? {} : { baseUrl }), + apiKey, + models, + selectedModelId: opts.defaultModel ?? '', + thinking: false, + }); + + // Resolve the final `defaultModel`: + // - If the caller asked for one, `applyCatalogProvider` already set it. + // - Else, restore the previous default ONLY when its alias still resolves + // after the catalog refresh; the catalog may have dropped the old + // model, in which case restoring would point default_model at a + // non-existent alias and break the next session. + if (opts.defaultModel === undefined) { + const stillResolves = + previousDefaultModel !== undefined && + config.models?.[previousDefaultModel] !== undefined; + config.defaultModel = stillResolves ? previousDefaultModel : undefined; + } + + // Always restore `defaultThinking` from what was there before — including + // `undefined`. Persisting `false` when the user never set it would make + // `resolveThinkingLevel` (agent-core/src/agent/config/thinking.ts) treat + // it as an explicit "off" request and silently disable thinking, even + // for thinking-capable models. + config.defaultThinking = previousDefaultThinking; + + await harness.setConfig({ + providers: config.providers, + models: config.models, + defaultModel: config.defaultModel, + defaultThinking: config.defaultThinking, + }); + + const displayName = entry.name ?? providerId; + deps.stdout.write( + `Imported ${displayName} (${providerId}) with ${String(models.length)} model${models.length === 1 ? '' : 's'} from ${url}.\n`, + ); + if (opts.defaultModel !== undefined) { + deps.stdout.write(`Default model set to ${providerId}/${opts.defaultModel}.\n`); + } +} + +async function loadCatalogOrExit(deps: ProviderDeps, url: string): Promise { + try { + return await fetchCatalog(url); + } catch (error) { + const suffix = error instanceof CatalogFetchError ? ` (HTTP ${String(error.status)})` : ''; + deps.stderr.write(`Failed to fetch catalog from ${url}${suffix}: ${errorMessage(error)}\n`); + deps.exit(1); + } +} + +export function registerProviderCommand(parent: Command, deps?: Partial): void { + const provider = parent + .command('provider') + .description('Manage LLM providers non-interactively.'); + + provider + .command('add ') + .description('Import every provider listed in a custom registry (api.json).') + .option('--api-key ', 'Registry API key. Falls back to KIMI_REGISTRY_API_KEY.') + .action(async (url: string, options: { apiKey?: string }) => { + const resolved = resolveDeps(deps); + await handleProviderAdd(resolved, url, { apiKey: options.apiKey }); + }); + + provider + .command('remove ') + .description('Remove a provider and every model alias that referenced it.') + .action(async (providerId: string) => { + const resolved = resolveDeps(deps); + await handleProviderRemove(resolved, providerId); + }); + + provider + .command('list') + .description('Show configured providers and their model counts.') + .option('--json', 'Emit the raw providers/models config as JSON.', false) + .action(async (options: { json?: boolean }) => { + const resolved = resolveDeps(deps); + await handleProviderList(resolved, { json: options.json === true }); + }); + + const catalog = provider + .command('catalog') + .description('Discover and import providers from the public models.dev catalog.'); + + catalog + .command('list [providerId]') + .description('List providers in the catalog, or models when a providerId is given.') + .option('--filter ', 'Case-insensitive id/name substring filter.') + .option('--url ', `Override catalog URL. Defaults to ${DEFAULT_CATALOG_URL}.`) + .option('--json', 'Emit the matching catalog slice as JSON.', false) + .action( + async ( + providerId: string | undefined, + options: { filter?: string; url?: string; json?: boolean }, + ) => { + const resolved = resolveDeps(deps); + await handleCatalogList(resolved, providerId, { + json: options.json === true, + ...(options.filter === undefined ? {} : { filter: options.filter }), + ...(options.url === undefined ? {} : { url: options.url }), + }); + }, + ); + + catalog + .command('add ') + .description('Import a known provider from the catalog by id.') + .option('--api-key ', 'API key for the provider. Falls back to KIMI_REGISTRY_API_KEY.') + .option('--default-model ', 'Mark the imported model as default_model after import.') + .option('--url ', `Override catalog URL. Defaults to ${DEFAULT_CATALOG_URL}.`) + .action( + async ( + providerId: string, + options: { apiKey?: string; defaultModel?: string; url?: string }, + ) => { + const resolved = resolveDeps(deps); + await handleCatalogAdd(resolved, providerId, { + ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }), + ...(options.defaultModel === undefined ? {} : { defaultModel: options.defaultModel }), + ...(options.url === undefined ? {} : { url: options.url }), + }); + }, + ); +} + +function resolveDeps(overrides: Partial = {}): ProviderDeps { + let harness: KimiHarness | undefined; + const identity = createKimiCodeHostIdentity(); + return { + getHarness: + overrides.getHarness ?? + (() => { + harness ??= new KimiHarness({ identity }); + return harness; + }), + stdout: overrides.stdout ?? process.stdout, + stderr: overrides.stderr ?? process.stderr, + env: overrides.env ?? process.env, + exit: overrides.exit ?? ((code: number) => process.exit(code)), + }; +} + +function resolveApiKey(flag: string | undefined, env: NodeJS.ProcessEnv): string | undefined { + if (typeof flag === 'string' && flag.length > 0) return flag; + const fromEnv = env['KIMI_REGISTRY_API_KEY']; + if (typeof fromEnv === 'string' && fromEnv.length > 0) return fromEnv; + return undefined; +} + +function asManaged(config: KimiConfig): ManagedKimiConfigShape { + return config as unknown as ManagedKimiConfigShape; +} + +function providerSourceLabel(provider: KimiConfig['providers'][string]): string { + const source = provider.source; + if (source !== undefined) { + if (source['kind'] === 'apiJson' && typeof source['url'] === 'string') { + return `apiJson(${source['url']})`; + } + } + if (provider.oauth !== undefined) return 'oauth'; + return 'inline'; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index 0a8fd7748..d9a9f71c2 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -261,7 +261,7 @@ describe('CLI options parsing', () => { const commandNames: string[] = program.commands .filter((command) => !command.name().startsWith('__')) .map((command) => command.name()); - expect(commandNames).toEqual(['export', 'migrate']); + expect(commandNames).toEqual(['export', 'provider', 'migrate']); }); }); diff --git a/apps/kimi-code/test/cli/provider.test.ts b/apps/kimi-code/test/cli/provider.test.ts new file mode 100644 index 000000000..aef8cbc5f --- /dev/null +++ b/apps/kimi-code/test/cli/provider.test.ts @@ -0,0 +1,891 @@ +/** + * `kimi provider` CLI unit tests. The handlers receive an injected `getHarness` + * + capturing stdout/stderr, so we test the wiring end-to-end without booting + * a real harness or hitting the network. + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { Command } from 'commander'; +import type { KimiConfig } from '@moonshot-ai/kimi-code-sdk'; + +import { + handleCatalogAdd, + handleCatalogList, + handleProviderAdd, + handleProviderList, + handleProviderRemove, + registerProviderCommand, + type ProviderDeps, +} from '#/cli/sub/provider'; + +class ExitCalled extends Error { + constructor(public readonly code: number) { + super(`exit(${code})`); + } +} + +interface FakeHarness { + ensureConfigFile: () => Promise; + getConfig: () => Promise; + setConfig: (patch: Partial) => Promise; + removeProvider: (providerId: string) => Promise; +} + +function makeHarness(initial: KimiConfig): { + harness: FakeHarness; + current: () => KimiConfig; + setConfigCalls: Array>; + removeCalls: string[]; +} { + // `persisted` simulates the on-disk config; the real RPC's `removeProvider` + // reads from / writes to disk on every call (see + // `packages/agent-core/src/rpc/core-impl.ts removeKimiProvider`). Tests must + // model this: anything the handler builds up in its in-memory `config` + // object disappears unless it is flushed via `setConfig` BEFORE the next + // `removeProvider`. + let persisted: KimiConfig = structuredClone(initial); + const setConfigCalls: Array> = []; + const removeCalls: string[] = []; + const harness: FakeHarness = { + ensureConfigFile: async () => {}, + getConfig: async () => structuredClone(persisted), + setConfig: async (patch) => { + setConfigCalls.push(structuredClone(patch)); + // Mirror the real `setKimiConfig`: deep-merge with undefined keys + // skipped (see `agent-core/src/config/merge.ts deepMerge`). This is + // load-bearing for tests that assert `setConfig({defaultModel: + // undefined})` does NOT wipe a key from disk — only `removeProvider` + // can. + const next: Record = { ...persisted }; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) continue; + next[key] = value; + } + persisted = next as KimiConfig; + return structuredClone(persisted); + }, + removeProvider: async (providerId) => { + removeCalls.push(providerId); + const nextProviders = { ...persisted.providers }; + delete nextProviders[providerId]; + const nextModels = { ...persisted.models }; + let removedDefault = false; + for (const [alias, model] of Object.entries(nextModels)) { + if (model.provider === providerId) { + delete nextModels[alias]; + if (persisted.defaultModel === alias) removedDefault = true; + } + } + persisted = { ...persisted, providers: nextProviders, models: nextModels }; + if (removedDefault) persisted = { ...persisted, defaultModel: undefined }; + return structuredClone(persisted); + }, + }; + return { + harness, + current: () => persisted, + setConfigCalls, + removeCalls, + }; +} + +function makeDeps( + harness: FakeHarness, + overrides: Partial = {}, +): { + deps: ProviderDeps; + stdout: string[]; + stderr: string[]; + exitCodes: number[]; +} { + const stdout: string[] = []; + const stderr: string[] = []; + const exitCodes: number[] = []; + const deps: ProviderDeps = { + getHarness: () => harness as unknown as ProviderDeps extends { getHarness: () => infer R } + ? R + : never, + stdout: { + write: (chunk: string) => { + stdout.push(chunk); + return true; + }, + }, + stderr: { + write: (chunk: string) => { + stderr.push(chunk); + return true; + }, + }, + env: {}, + exit: ((code: number) => { + exitCodes.push(code); + throw new ExitCalled(code); + }) as ProviderDeps['exit'], + ...overrides, + }; + return { deps, stdout, stderr, exitCodes }; +} + +async function tryRun(fn: () => Promise): Promise { + try { + return await fn(); + } catch (error) { + if (error instanceof ExitCalled) return undefined; + throw error; + } +} + +const REGISTRY_URL = 'https://free-tokens.example.test/v1/models/api.json'; +const REGISTRY_BODY = { + kohub: { + id: 'kohub', + name: 'KoHub Anthropic', + api: 'https://free-tokens.example.test', + type: 'anthropic', + models: { + 'claude-opus-4-7': { id: 'claude-opus-4-7', name: 'Claude Opus 4-7', tool_call: true }, + }, + }, + 'kohub-responses': { + id: 'kohub-responses', + name: 'KoHub Responses', + api: 'https://free-tokens.example.test/v1', + type: 'openai_responses', + models: { + 'gpt-5.5': { id: 'gpt-5.5', name: 'GPT 5.5', reasoning: true }, + }, + }, +}; + +let originalFetch: typeof globalThis.fetch; + +beforeEach(() => { + originalFetch = globalThis.fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +function mockRegistryFetch(body: unknown = REGISTRY_BODY, status = 200): ReturnType { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + ); + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + return fetchMock; +} + +const CATALOG_BODY = { + anthropic: { + id: 'anthropic', + name: 'Anthropic', + npm: '@ai-sdk/anthropic', + api: 'https://api.anthropic.com', + env: ['ANTHROPIC_API_KEY'], + models: { + 'claude-opus-4-7': { + id: 'claude-opus-4-7', + name: 'Claude Opus 4.7', + limit: { context: 200_000, output: 64_000 }, + tool_call: true, + reasoning: true, + modalities: { input: ['text', 'image'], output: ['text'] }, + }, + 'claude-haiku-4-5': { + id: 'claude-haiku-4-5', + name: 'Claude Haiku 4.5', + limit: { context: 200_000, output: 16_000 }, + tool_call: true, + modalities: { input: ['text'], output: ['text'] }, + }, + }, + }, + openai: { + id: 'openai', + name: 'OpenAI', + npm: '@ai-sdk/openai', + api: 'https://api.openai.com/v1', + env: ['OPENAI_API_KEY'], + models: { + 'gpt-5.5': { + id: 'gpt-5.5', + name: 'GPT 5.5', + limit: { context: 1_048_576, output: 128_000 }, + tool_call: true, + reasoning: true, + modalities: { input: ['text', 'image'], output: ['text'] }, + }, + }, + }, +}; + +describe('kimi provider add', () => { + it('imports providers and models from a custom registry, persisting source on each provider', async () => { + const fetchMock = mockRegistryFetch(); + const { harness, current, setConfigCalls } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stdout, stderr, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleProviderAdd(deps, REGISTRY_URL, { apiKey: 'sk-test-token' }), + ); + + expect(exitCodes).toEqual([]); + expect(stderr.join('')).toBe(''); + expect(fetchMock).toHaveBeenCalledWith( + REGISTRY_URL, + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer sk-test-token' }), + }), + ); + + const finalConfig = current(); + expect(Object.keys(finalConfig.providers).toSorted()).toEqual(['kohub', 'kohub-responses']); + const kohub = finalConfig.providers['kohub']!; + expect(kohub.type).toBe('anthropic'); + expect(kohub.baseUrl).toBe('https://free-tokens.example.test'); + expect(kohub.apiKey).toBe('sk-test-token'); + expect(kohub.source).toEqual({ + kind: 'apiJson', + url: REGISTRY_URL, + apiKey: 'sk-test-token', + }); + + expect(finalConfig.models?.['kohub/claude-opus-4-7']).toMatchObject({ + provider: 'kohub', + model: 'claude-opus-4-7', + }); + expect(finalConfig.models?.['kohub-responses/gpt-5.5']).toMatchObject({ + provider: 'kohub-responses', + model: 'gpt-5.5', + }); + + // The single setConfig patch should carry both providers and models. + expect(setConfigCalls).toHaveLength(1); + expect(Object.keys(setConfigCalls[0]?.providers ?? {}).toSorted()).toEqual([ + 'kohub', + 'kohub-responses', + ]); + + const output = stdout.join(''); + expect(output).toContain('Imported 2 providers (2 models)'); + expect(output).toContain('- kohub'); + expect(output).toContain('- kohub-responses'); + }); + + it('drops a stale provider before re-applying when the id already exists', async () => { + mockRegistryFetch(); + const initial: KimiConfig = { + providers: { + kohub: { + type: 'kimi', + baseUrl: 'https://stale.example.test', + apiKey: 'old', + }, + }, + models: { + 'kohub/stale-model': { + provider: 'kohub', + model: 'stale-model', + maxContextSize: 1024, + capabilities: [], + }, + }, + } as unknown as KimiConfig; + const { harness, removeCalls, current } = makeHarness(initial); + const { deps, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleProviderAdd(deps, REGISTRY_URL, { apiKey: 'sk-new' }), + ); + + expect(exitCodes).toEqual([]); + expect(removeCalls).toContain('kohub'); + // The stale model alias must be gone; the registry's alias must be in. + expect(current().models?.['kohub/stale-model']).toBeUndefined(); + expect(current().models?.['kohub/claude-opus-4-7']).toBeDefined(); + }); + + it('preserves newly-imported providers when a later registry entry replaces an existing id', async () => { + // Regression test for the codex P1: `harness.removeProvider` re-reads + // from disk on each call, so applying the loop body without flushing + // would silently drop providers added earlier in the same iteration. + // The handler now removes every stale id up front in a single batch. + mockRegistryFetch(); + const initial: KimiConfig = { + providers: { + // The registry will replace this one. + 'kohub-responses': { + type: 'openai_responses', + baseUrl: 'https://stale.example.test/v1', + apiKey: 'old', + }, + }, + models: { + 'kohub-responses/legacy-model': { + provider: 'kohub-responses', + model: 'legacy-model', + maxContextSize: 1024, + capabilities: [], + }, + }, + } as unknown as KimiConfig; + const { harness, current } = makeHarness(initial); + const { deps, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleProviderAdd(deps, REGISTRY_URL, { apiKey: 'sk-fresh' }), + ); + + expect(exitCodes).toEqual([]); + const final = current(); + // BOTH providers must end up in the final config — `kohub` was newly + // added in the loop, `kohub-responses` was replaced. The old bug dropped + // `kohub` because the second iteration's `removeProvider` reloaded a + // disk-backed config that had not yet been persisted with `kohub`. + expect(final.providers['kohub']).toBeDefined(); + expect(final.providers['kohub-responses']).toBeDefined(); + expect(final.providers['kohub-responses']?.apiKey).toBe('sk-fresh'); + expect(final.models?.['kohub/claude-opus-4-7']).toBeDefined(); + expect(final.models?.['kohub-responses/gpt-5.5']).toBeDefined(); + expect(final.models?.['kohub-responses/legacy-model']).toBeUndefined(); + }); + + it('reads the api key from KIMI_REGISTRY_API_KEY when --api-key is omitted', async () => { + const fetchMock = mockRegistryFetch(); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, exitCodes } = makeDeps(harness, { + env: { KIMI_REGISTRY_API_KEY: 'sk-env-token' }, + }); + + await tryRun(() => handleProviderAdd(deps, REGISTRY_URL, {})); + + expect(exitCodes).toEqual([]); + expect(fetchMock).toHaveBeenCalledWith( + REGISTRY_URL, + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer sk-env-token' }), + }), + ); + }); + + it('exits 1 with a clear message when no api key is supplied anywhere', async () => { + const fetchMock = mockRegistryFetch(); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stderr, exitCodes } = makeDeps(harness); + + await tryRun(() => handleProviderAdd(deps, REGISTRY_URL, {})); + + expect(exitCodes).toEqual([1]); + expect(stderr.join('')).toMatch(/missing api key/i); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('exits 1 when the registry fetch fails with an HTTP error', async () => { + mockRegistryFetch({ message: 'invalid token' }, 401); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stderr, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleProviderAdd(deps, REGISTRY_URL, { apiKey: 'sk-bad' }), + ); + + expect(exitCodes).toEqual([1]); + expect(stderr.join('')).toMatch(/HTTP 401/); + }); +}); + +describe('kimi provider remove', () => { + it('removes a provider and reports success', async () => { + const initial: KimiConfig = { + providers: { + kohub: { type: 'anthropic', baseUrl: 'https://x', apiKey: 'k' }, + }, + models: { + 'kohub/m': { + provider: 'kohub', + model: 'm', + maxContextSize: 1024, + capabilities: [], + }, + }, + } as unknown as KimiConfig; + const { harness, removeCalls, current } = makeHarness(initial); + const { deps, stdout, exitCodes } = makeDeps(harness); + + await tryRun(() => handleProviderRemove(deps, 'kohub')); + + expect(exitCodes).toEqual([]); + expect(removeCalls).toEqual(['kohub']); + expect(current().providers['kohub']).toBeUndefined(); + expect(stdout.join('')).toContain('Removed provider "kohub"'); + }); + + it('exits 1 when the provider id does not exist', async () => { + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stderr, exitCodes } = makeDeps(harness); + + await tryRun(() => handleProviderRemove(deps, 'nope')); + + expect(exitCodes).toEqual([1]); + expect(stderr.join('')).toContain('Provider "nope" not found'); + }); +}); + +describe('kimi provider list', () => { + const config: KimiConfig = { + providers: { + kohub: { + type: 'anthropic', + baseUrl: 'https://x', + apiKey: 'k', + source: { kind: 'apiJson', url: REGISTRY_URL, apiKey: 'k' }, + }, + 'managed:kimi-code': { + type: 'kimi', + baseUrl: 'https://api.kimi.com/coding/v1', + oauth: { storage: 'file', key: 'oauth/kimi-code' }, + }, + manual: { type: 'openai', baseUrl: 'https://y', apiKey: 'm' }, + }, + models: { + 'kohub/a': { + provider: 'kohub', + model: 'a', + maxContextSize: 1024, + capabilities: [], + }, + 'kohub/b': { + provider: 'kohub', + model: 'b', + maxContextSize: 1024, + capabilities: [], + }, + 'manual/x': { + provider: 'manual', + model: 'x', + maxContextSize: 1024, + capabilities: [], + }, + }, + defaultModel: 'kohub/a', + } as unknown as KimiConfig; + + it('renders one row per provider with counts and source labels', async () => { + const { harness } = makeHarness(config); + const { deps, stdout } = makeDeps(harness); + + await tryRun(() => handleProviderList(deps, { json: false })); + + const out = stdout.join(''); + expect(out).toMatch(/kohub\s+type=anthropic\s+models=2\s+source=apiJson\(/); + expect(out).toMatch(/managed:kimi-code\s+type=kimi\s+models=0\s+source=oauth/); + expect(out).toMatch(/manual\s+type=openai\s+models=1\s+source=inline/); + expect(out).toContain('Default model: kohub/a'); + }); + + it('prints a friendly message when nothing is configured', async () => { + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stdout } = makeDeps(harness); + + await tryRun(() => handleProviderList(deps, { json: false })); + + expect(stdout.join('')).toContain('No providers configured'); + }); + + it('emits parseable JSON with --json', async () => { + const { harness } = makeHarness(config); + const { deps, stdout } = makeDeps(harness); + + await tryRun(() => handleProviderList(deps, { json: true })); + + const parsed = JSON.parse(stdout.join('')) as { + providers: Record; + models: Record; + }; + expect(Object.keys(parsed.providers).toSorted()).toEqual([ + 'kohub', + 'managed:kimi-code', + 'manual', + ]); + expect(Object.keys(parsed.models)).toContain('kohub/a'); + }); +}); + +describe('registerProviderCommand', () => { + it('describes the user-facing subcommand and routes flags through commander', async () => { + const fetchMock = mockRegistryFetch(); + const { harness, current } = makeHarness({ providers: {} } as KimiConfig); + const { deps, exitCodes, stdout } = makeDeps(harness); + + const program = new Command('kimi'); + registerProviderCommand(program, deps); + + const providerCmd = program.commands.find((c) => c.name() === 'provider'); + expect(providerCmd?.description()).toMatch(/Manage LLM providers/i); + + await tryRun(() => + program.parseAsync( + ['node', 'kimi', 'provider', 'add', REGISTRY_URL, '--api-key', 'sk-cli'], + { from: 'node' }, + ), + ); + + expect(exitCodes).toEqual([]); + expect(fetchMock).toHaveBeenCalledWith( + REGISTRY_URL, + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer sk-cli' }), + }), + ); + expect(Object.keys(current().providers).toSorted()).toEqual(['kohub', 'kohub-responses']); + expect(stdout.join('')).toContain('Imported 2 providers'); + }); +}); + +describe('kimi provider catalog list', () => { + it('lists catalog providers with wire/model counts, sorted by id', async () => { + mockRegistryFetch(CATALOG_BODY); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stdout, exitCodes } = makeDeps(harness); + + await tryRun(() => handleCatalogList(deps, undefined, { json: false })); + + expect(exitCodes).toEqual([]); + const out = stdout.join(''); + expect(out).toMatch(/^anthropic\s+wire=anthropic\s+models=2\s+Anthropic\n/); + expect(out).toMatch(/openai\s+wire=openai\s+models=1\s+OpenAI/); + // anthropic before openai (alphabetical). + expect(out.indexOf('anthropic')).toBeLessThan(out.indexOf('openai')); + }); + + it('filters case-insensitively by id and name substring', async () => { + mockRegistryFetch(CATALOG_BODY); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stdout } = makeDeps(harness); + + await tryRun(() => handleCatalogList(deps, undefined, { json: false, filter: 'open' })); + + const out = stdout.join(''); + expect(out).toContain('openai'); + expect(out).not.toContain('anthropic'); + }); + + it('drills into a specific providerId and lists its models with capabilities', async () => { + mockRegistryFetch(CATALOG_BODY); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stdout } = makeDeps(harness); + + await tryRun(() => handleCatalogList(deps, 'anthropic', { json: false })); + + const out = stdout.join(''); + expect(out).toMatch(/^Anthropic \(anthropic\)/); + expect(out).toMatch(/claude-opus-4-7\s+ctx=200000.*tool_use.*thinking.*image_in/); + expect(out).toMatch(/claude-haiku-4-5\s+ctx=200000.*tool_use/); + }); + + it('exits 1 when the requested providerId is missing from the catalog', async () => { + mockRegistryFetch(CATALOG_BODY); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stderr, exitCodes } = makeDeps(harness); + + await tryRun(() => handleCatalogList(deps, 'unknown', { json: false })); + + expect(exitCodes).toEqual([1]); + expect(stderr.join('')).toContain('Provider "unknown" not found in catalog'); + }); + + it('emits parseable JSON for the providerId view', async () => { + mockRegistryFetch(CATALOG_BODY); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stdout } = makeDeps(harness); + + await tryRun(() => handleCatalogList(deps, 'openai', { json: true })); + + const parsed = JSON.parse(stdout.join('')) as { + providerId: string; + models: Array<{ id: string }>; + }; + expect(parsed.providerId).toBe('openai'); + expect(parsed.models.map((m) => m.id)).toEqual(['gpt-5.5']); + }); + + it('honors --url override when supplied', async () => { + const fetchMock = mockRegistryFetch(CATALOG_BODY); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps } = makeDeps(harness); + + await tryRun(() => + handleCatalogList(deps, undefined, { json: true, url: 'https://example.test/catalog.json' }), + ); + + expect(fetchMock).toHaveBeenCalledWith('https://example.test/catalog.json', expect.any(Object)); + }); +}); + +describe('kimi provider catalog add', () => { + it('imports a provider from the catalog without changing the default model', async () => { + mockRegistryFetch(CATALOG_BODY); + const initial: KimiConfig = { + providers: { + other: { type: 'kimi', baseUrl: 'https://x', apiKey: 'k' }, + }, + models: { + 'other/main': { + provider: 'other', + model: 'main', + maxContextSize: 1024, + capabilities: [], + }, + }, + defaultModel: 'other/main', + defaultThinking: true, + } as unknown as KimiConfig; + const { harness, current, setConfigCalls } = makeHarness(initial); + const { deps, stdout, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleCatalogAdd(deps, 'anthropic', { apiKey: 'sk-ant-token' }), + ); + + expect(exitCodes).toEqual([]); + const finalConfig = current(); + expect(finalConfig.providers['anthropic']).toMatchObject({ + type: 'anthropic', + apiKey: 'sk-ant-token', + }); + // Catalog import populates the model aliases. + expect(finalConfig.models?.['anthropic/claude-opus-4-7']).toMatchObject({ + provider: 'anthropic', + model: 'claude-opus-4-7', + }); + expect(finalConfig.models?.['anthropic/claude-haiku-4-5']).toBeDefined(); + // The unrelated provider's model survives, and remains the default. + expect(finalConfig.models?.['other/main']).toBeDefined(); + expect(finalConfig.defaultModel).toBe('other/main'); + expect(finalConfig.defaultThinking).toBe(true); + // The patch sent over `setConfig` must explicitly carry the preserved default. + expect(setConfigCalls[0]?.defaultModel).toBe('other/main'); + expect(stdout.join('')).toContain('Imported Anthropic (anthropic)'); + }); + + it('sets default_model when --default-model is supplied and the model exists', async () => { + mockRegistryFetch(CATALOG_BODY); + const { harness, current, setConfigCalls } = makeHarness({ + providers: {}, + } as KimiConfig); + const { deps, stdout, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleCatalogAdd(deps, 'anthropic', { + apiKey: 'sk-ant-token', + defaultModel: 'claude-opus-4-7', + }), + ); + + expect(exitCodes).toEqual([]); + expect(current().defaultModel).toBe('anthropic/claude-opus-4-7'); + expect(setConfigCalls[0]?.defaultModel).toBe('anthropic/claude-opus-4-7'); + expect(stdout.join('')).toContain('Default model set to anthropic/claude-opus-4-7'); + }); + + it('rejects an unknown --default-model with a helpful hint', async () => { + mockRegistryFetch(CATALOG_BODY); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stderr, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleCatalogAdd(deps, 'anthropic', { + apiKey: 'sk-ant-token', + defaultModel: 'does-not-exist', + }), + ); + + expect(exitCodes).toEqual([1]); + const err = stderr.join(''); + expect(err).toContain('"does-not-exist" is not in provider "anthropic"'); + expect(err).toContain('kimi provider catalog list anthropic'); + }); + + it('preserves an existing default_model when re-importing the same provider without --default-model', async () => { + // Regression test for the codex P2: `removeProvider` clears + // `defaultModel` if it pointed at one of the provider's aliases. The + // handler must capture the previous default BEFORE calling + // `removeProvider`, otherwise rotating the api key on an already- + // configured provider would silently wipe the user's chosen default. + mockRegistryFetch(CATALOG_BODY); + const initial: KimiConfig = { + providers: { + anthropic: { + type: 'anthropic', + baseUrl: 'https://api.anthropic.com', + apiKey: 'sk-old', + }, + }, + models: { + 'anthropic/claude-opus-4-7': { + provider: 'anthropic', + model: 'claude-opus-4-7', + maxContextSize: 200_000, + capabilities: ['tool_use', 'thinking', 'image_in'], + }, + }, + defaultModel: 'anthropic/claude-opus-4-7', + defaultThinking: true, + } as unknown as KimiConfig; + const { harness, current } = makeHarness(initial); + const { deps, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleCatalogAdd(deps, 'anthropic', { apiKey: 'sk-rotated' }), + ); + + expect(exitCodes).toEqual([]); + expect(current().providers['anthropic']?.apiKey).toBe('sk-rotated'); + // Previous default and thinking flag must survive the re-import. + expect(current().defaultModel).toBe('anthropic/claude-opus-4-7'); + expect(current().defaultThinking).toBe(true); + }); + + it('preserves default_thinking when --default-model is supplied to a thinking-capable model', async () => { + // Regression test for the codex P2: `applyCatalogProvider` always + // assigns `defaultThinking` from `options.thinking`. Hardcoding `false` + // silently disabled thinking even when the user previously had it on + // and is just importing a known provider. The handler now threads the + // previous value through. + mockRegistryFetch(CATALOG_BODY); + const initial: KimiConfig = { + providers: {}, + defaultThinking: true, + } as unknown as KimiConfig; + const { harness, current, setConfigCalls } = makeHarness(initial); + const { deps, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleCatalogAdd(deps, 'anthropic', { + apiKey: 'sk-ant', + defaultModel: 'claude-opus-4-7', + }), + ); + + expect(exitCodes).toEqual([]); + expect(current().defaultModel).toBe('anthropic/claude-opus-4-7'); + expect(current().defaultThinking).toBe(true); + expect(setConfigCalls[0]?.defaultThinking).toBe(true); + }); + + it('does not persist default_thinking=false for first-time setup with --default-model', async () => { + // Regression test for codex P2 follow-up: previously the handler fell + // back to `false` when `defaultThinking` was unset, but + // `resolveThinkingLevel` treats `defaultThinking === false` as an + // explicit "off" request. A fresh `kimi provider catalog add + // anthropic --default-model claude-opus-4-7` must NOT silently disable + // thinking — it should leave `defaultThinking` unset so the runtime + // uses the per-model default. + mockRegistryFetch(CATALOG_BODY); + // Note: `defaultThinking` is omitted on purpose to model a fresh user. + const { harness, current, setConfigCalls } = makeHarness({ + providers: {}, + } as KimiConfig); + const { deps, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleCatalogAdd(deps, 'anthropic', { + apiKey: 'sk-ant', + defaultModel: 'claude-opus-4-7', + }), + ); + + expect(exitCodes).toEqual([]); + expect(current().defaultModel).toBe('anthropic/claude-opus-4-7'); + // Must NOT be `false`. `undefined` lets the runtime resolver pick the + // per-model default; `false` would force `'off'`. + expect(current().defaultThinking).toBeUndefined(); + expect(setConfigCalls[0]?.defaultThinking).toBeUndefined(); + }); + + it('drops a stale default_model when the catalog refresh no longer contains it', async () => { + // Regression test for codex P2: when the user previously chose + // `anthropic/legacy` as default and a refresh of the same provider no + // longer ships that model, restoring the previous default would point + // `default_model` at a non-existent alias and break the next session. + // The handler now checks whether the alias still resolves and clears + // it otherwise. + mockRegistryFetch(CATALOG_BODY); + const initial: KimiConfig = { + providers: { + anthropic: { + type: 'anthropic', + baseUrl: 'https://api.anthropic.com', + apiKey: 'sk-old', + }, + }, + models: { + 'anthropic/legacy-claude': { + provider: 'anthropic', + model: 'legacy-claude', + maxContextSize: 200_000, + capabilities: [], + }, + }, + defaultModel: 'anthropic/legacy-claude', + } as unknown as KimiConfig; + const { harness, current } = makeHarness(initial); + const { deps, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleCatalogAdd(deps, 'anthropic', { apiKey: 'sk-rotated' }), + ); + + expect(exitCodes).toEqual([]); + // The legacy alias must have been replaced by the catalog's models. + expect(current().models?.['anthropic/legacy-claude']).toBeUndefined(); + expect(current().models?.['anthropic/claude-opus-4-7']).toBeDefined(); + // The dangling default must NOT have been restored — it would point at + // a non-existent alias. The handler clears it instead. + expect(current().defaultModel).toBeUndefined(); + }); + + it('falls back to KIMI_REGISTRY_API_KEY when --api-key is omitted', async () => { + mockRegistryFetch(CATALOG_BODY); + const { harness, current } = makeHarness({ providers: {} } as KimiConfig); + const { deps, exitCodes } = makeDeps(harness, { + env: { KIMI_REGISTRY_API_KEY: 'sk-env' }, + }); + + await tryRun(() => handleCatalogAdd(deps, 'openai', {})); + + expect(exitCodes).toEqual([]); + expect(current().providers['openai']).toMatchObject({ apiKey: 'sk-env' }); + }); + + it('exits 1 when the api key is missing and skips the network', async () => { + const fetchMock = mockRegistryFetch(CATALOG_BODY); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stderr, exitCodes } = makeDeps(harness); + + await tryRun(() => handleCatalogAdd(deps, 'anthropic', {})); + + expect(exitCodes).toEqual([1]); + expect(stderr.join('')).toMatch(/missing api key/i); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('exits 1 when the providerId is missing from the catalog', async () => { + mockRegistryFetch(CATALOG_BODY); + const { harness } = makeHarness({ providers: {} } as KimiConfig); + const { deps, stderr, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleCatalogAdd(deps, 'no-such-id', { apiKey: 'sk-x' }), + ); + + expect(exitCodes).toEqual([1]); + expect(stderr.join('')).toContain('Provider "no-such-id" not found in catalog'); + }); +}); diff --git a/docs/en/configuration/providers.md b/docs/en/configuration/providers.md index e9047308d..e8baf1c90 100644 --- a/docs/en/configuration/providers.md +++ b/docs/en/configuration/providers.md @@ -46,6 +46,8 @@ When you add a provider or switch models, the **tabbed model selector** splits t The Kimi Code OAuth provider (the account you sign into with `/login`) is intentionally hidden from `/provider`; manage that account with `/login` and `/logout` instead. ::: +For scripted or non-interactive setups, the same custom-registry import is available from the shell via the [`kimi provider`](../reference/kimi-command.md#kimi-provider) subcommand. + ## `kimi` `kimi` connects to the Moonshot AI API using the OpenAI-compatible protocol. diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index cca98e032..317fb1f32 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -161,3 +161,90 @@ kimi migrate ``` If you previously used an older version of kimi-cli, run this command to migrate historical sessions, configuration, and other data to kimi-code to avoid data loss. For the full migration flow, what gets migrated, and things to watch out for, see [Migrating from kimi-cli](../guides/migration.md). + +### `kimi provider` + +Manage LLM providers from the shell — the non-interactive equivalent of the TUI `/provider` command. Useful for scripting, CI bootstrap, and provisioning a fresh machine. + +```sh +kimi provider [options] +``` + +Three actions are available: + +#### `kimi provider add ` + +Import every provider listed in a custom registry (an `api.json` document). The command fetches the registry, creates one `[providers.]` table per top-level entry, populates `[models.]` for each model in the document, and persists the `source = { kind = "apiJson", url, api_key }` block on every provider so the next TUI launch refreshes the model list automatically. + +| Argument / Option | Description | +| --- | --- | +| `` | Registry URL, e.g. `https://free-tokens.msh.team/v1/models/api.json`. | +| `--api-key ` | Bearer token sent with the registry fetch. Falls back to the `KIMI_REGISTRY_API_KEY` environment variable when omitted. Required. | + +```sh +# One-line import — every provider and model in the registry lands in ~/.kimi-code/config.toml +kimi provider add https://free-tokens.msh.team/v1/models/api.json --api-key YOUR_KEY + +# Or via environment variable, for CI / .envrc-style workflows +KIMI_REGISTRY_API_KEY=YOUR_KEY kimi provider add https://free-tokens.msh.team/v1/models/api.json +``` + +If a provider id already exists, it is replaced (its stale model aliases are removed first, mirroring the TUI flow). No default model is selected — pick one later via `-m` or `/model` inside the TUI. + +#### `kimi provider remove ` + +Delete a provider and every model alias that referenced it. If the removed provider was the source of `default_model`, that field is cleared. + +```sh +kimi provider remove kohub +``` + +#### `kimi provider list` + +Print one row per configured provider with its type, model count, and source (`apiJson(...)`, `oauth`, or `inline`). Add `--json` to emit the raw `providers` and `models` tables as JSON for further processing. + +```sh +kimi provider list +kimi provider list --json | jq '.providers | keys' +``` + +#### `kimi provider catalog list [providerId]` + +Discover providers known to the public [models.dev](https://models.dev/) catalog without writing anything. With no argument, prints one row per provider showing its inferred wire type and model count. With a `providerId`, drills into that provider and lists each model with its context window and capabilities. + +| Argument / Option | Description | +| --- | --- | +| `[providerId]` | Optional. Catalog provider id to drill into. | +| `--filter ` | Case-insensitive id/name substring filter (top-level only). | +| `--url ` | Override catalog URL. Defaults to `https://models.dev/api.json`. | +| `--json` | Emit the matching catalog slice as JSON. | + +```sh +# Browse providers +kimi provider catalog list +kimi provider catalog list --filter anthropic + +# Inspect one provider's models +kimi provider catalog list anthropic +``` + +#### `kimi provider catalog add ` + +Import a known provider directly from the catalog. The catalog supplies the wire type, base URL, model identifiers, context limits, and capabilities — you only have to supply the API key. + +| Argument / Option | Description | +| --- | --- | +| `` | Catalog provider id, e.g. `anthropic`, `openai`, `google`. | +| `--api-key ` | API key for the provider. Falls back to `KIMI_REGISTRY_API_KEY`. Required. | +| `--default-model ` | Optional. Set `default_model` to `/` after import. The model must be one listed by `catalog list `. | +| `--url ` | Override catalog URL. Defaults to `https://models.dev/api.json`. | + +When `--default-model` is omitted, the existing `default_model` is preserved (you can still pick a model later via `-m` or the TUI `/model` command). + +```sh +# Look up what's available +kimi provider catalog list anthropic + +# Import and set the default in one go +kimi provider catalog add anthropic --api-key sk-ant-... --default-model claude-opus-4-7 +``` diff --git a/docs/zh/configuration/providers.md b/docs/zh/configuration/providers.md index 283ee8b4a..05c444f38 100644 --- a/docs/zh/configuration/providers.md +++ b/docs/zh/configuration/providers.md @@ -46,6 +46,8 @@ ANTHROPIC_BASE_URL = "https://my-proxy.example.com" 通过 `/login` 登录的 Kimi Code OAuth 供应商(托管账号)在 `/provider` 中会被故意隐藏;该账号请通过 `/login` 与 `/logout` 管理。 ::: +如果需要脚本化或在非交互环境下完成相同操作,可以通过 shell 中的 [`kimi provider`](../reference/kimi-command.md#kimi-provider) 子命令导入同样的自定义 registry。 + ## `kimi` `kimi` 通过 OpenAI 兼容协议对接 Moonshot AI。 diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index d50c9ae0a..94b74aabc 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -161,3 +161,90 @@ kimi migrate ``` 如果你之前使用过旧版 kimi-cli,可以运行此命令将历史会话、配置等数据迁移到 kimi-code 中,避免数据丢失。完整的迁移流程、迁移内容与注意事项见 [从 kimi-cli 迁移](../guides/migration.md)。 + +### `kimi provider` + +在 shell 中管理供应商,相当于 TUI 中 `/provider` 的非交互版本。适合脚本化部署、CI 初始化、以及在新机器上一行完成配置。 + +```sh +kimi provider [options] +``` + +包含三个动作: + +#### `kimi provider add ` + +从自定义 registry(一份 `api.json` 文档)批量导入所有供应商。命令会拉取 registry,为每个顶层条目创建 `[providers.]`,为每个模型创建 `[models.]`,并在每个供应商上写入 `source = { kind = "apiJson", url, api_key }`,使下次 TUI 启动时自动刷新模型列表。 + +| 参数 / 选项 | 说明 | +| --- | --- | +| `` | Registry 地址,例如 `https://free-tokens.msh.team/v1/models/api.json`。 | +| `--api-key ` | 访问 registry 时携带的 Bearer token。未传时回退到环境变量 `KIMI_REGISTRY_API_KEY`。必填。 | + +```sh +# 一行导入:registry 中的所有 provider 与 model 全部写入 ~/.kimi-code/config.toml +kimi provider add https://free-tokens.msh.team/v1/models/api.json --api-key YOUR_KEY + +# 或通过环境变量,便于 CI / .envrc 等场景 +KIMI_REGISTRY_API_KEY=YOUR_KEY kimi provider add https://free-tokens.msh.team/v1/models/api.json +``` + +如果某个 provider id 已存在,会先删除(包括其残留的模型 alias),再按 registry 重新写入,与 TUI 的行为一致。不会自动设置默认模型 —— 后续可以用 `-m` 或 TUI 内的 `/model` 选择。 + +#### `kimi provider remove ` + +删除指定供应商及其所有模型 alias。如果被删除的供应商正好是 `default_model` 所属,则同时清空 `default_model`。 + +```sh +kimi provider remove kohub +``` + +#### `kimi provider list` + +按行打印每个已配置的供应商,包含类型、模型数量、来源(`apiJson(...)`、`oauth` 或 `inline`)。加 `--json` 可输出原始的 `providers` 和 `models` 表,便于程序化处理。 + +```sh +kimi provider list +kimi provider list --json | jq '.providers | keys' +``` + +#### `kimi provider catalog list [providerId]` + +在不写入任何配置的情况下浏览公开的 [models.dev](https://models.dev/) 模型目录。不传参数时按行打印每个供应商及其推断出的协议类型和模型数量;传 `providerId` 时进入该供应商详情,逐个列出模型,附上下文窗口和能力。 + +| 参数 / 选项 | 说明 | +| --- | --- | +| `[providerId]` | 可选。要查看的供应商 id。 | +| `--filter ` | 顶层视图下,按 id 或 name 大小写不敏感子串过滤。 | +| `--url ` | 覆盖 catalog 地址,默认 `https://models.dev/api.json`。 | +| `--json` | 以 JSON 形式输出匹配片段。 | + +```sh +# 浏览供应商 +kimi provider catalog list +kimi provider catalog list --filter anthropic + +# 查看某个供应商的所有模型 +kimi provider catalog list anthropic +``` + +#### `kimi provider catalog add ` + +按 id 从 catalog 直接导入一个已知供应商。协议类型、base URL、模型标识、上下文限制、能力都由 catalog 提供,你只需要提供 API key。 + +| 参数 / 选项 | 说明 | +| --- | --- | +| `` | catalog 中的供应商 id,例如 `anthropic`、`openai`、`google`。 | +| `--api-key ` | 供应商 API key。未传时回退到环境变量 `KIMI_REGISTRY_API_KEY`。必填。 | +| `--default-model ` | 可选。导入后把 `default_model` 设置为 `/`。`` 必须在 `catalog list ` 中存在。 | +| `--url ` | 覆盖 catalog 地址,默认 `https://models.dev/api.json`。 | + +不传 `--default-model` 时保留现有的 `default_model`,后续可以用 `-m` 或 TUI 内的 `/model` 选择。 + +```sh +# 看可选项 +kimi provider catalog list anthropic + +# 一行导入并设默认 +kimi provider catalog add anthropic --api-key sk-ant-... --default-model claude-opus-4-7 +```