feat(cli): add kimi provider subcommand for non-interactive provider management (#313)

* feat(cli): add `kimi provider` subcommand

Add a non-interactive equivalent of the TUI `/provider` command:

- `kimi provider add <url> --api-key <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 <id>` 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 <providerId> --api-key <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<string, unknown>` 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>
This commit is contained in:
7Sageer 2026-06-02 14:58:47 +08:00 committed by GitHub
parent 0d074f1bae
commit 3c5dee8836
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1601 additions and 1 deletions

View file

@ -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.

View file

@ -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

View file

@ -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<void> {
const apiKey = resolveApiKey(opts.apiKey, deps.env);
if (apiKey === undefined) {
deps.stderr.write(
'Missing API key. Pass --api-key <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<ReturnType<typeof fetchCustomRegistry>>;
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<void> {
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<void> {
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<string, string[]>();
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<void> {
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<string, CatalogProviderEntry> = {};
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<void> {
const apiKey = resolveApiKey(opts.apiKey, deps.env);
if (apiKey === undefined) {
deps.stderr.write(
'Missing API key. Pass --api-key <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<Catalog> {
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<ProviderDeps>): void {
const provider = parent
.command('provider')
.description('Manage LLM providers non-interactively.');
provider
.command('add <url>')
.description('Import every provider listed in a custom registry (api.json).')
.option('--api-key <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 <providerId>')
.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 <substring>', 'Case-insensitive id/name substring filter.')
.option('--url <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 <providerId>')
.description('Import a known provider from the catalog by id.')
.option('--api-key <key>', 'API key for the provider. Falls back to KIMI_REGISTRY_API_KEY.')
.option('--default-model <modelId>', 'Mark the imported model as default_model after import.')
.option('--url <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> = {}): 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);
}

View file

@ -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']);
});
});

View file

@ -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<void>;
getConfig: () => Promise<KimiConfig>;
setConfig: (patch: Partial<KimiConfig>) => Promise<KimiConfig>;
removeProvider: (providerId: string) => Promise<KimiConfig>;
}
function makeHarness(initial: KimiConfig): {
harness: FakeHarness;
current: () => KimiConfig;
setConfigCalls: Array<Partial<KimiConfig>>;
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<Partial<KimiConfig>> = [];
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<string, unknown> = { ...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<ProviderDeps> = {},
): {
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<T>(fn: () => Promise<T>): Promise<T | undefined> {
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<typeof vi.fn> {
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<string, unknown>;
models: Record<string, unknown>;
};
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');
});
});

View file

@ -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.

View file

@ -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 <action> [options]
```
Three actions are available:
#### `kimi provider add <url>`
Import every provider listed in a custom registry (an `api.json` document). The command fetches the registry, creates one `[providers.<id>]` table per top-level entry, populates `[models.<alias>]` 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 |
| --- | --- |
| `<url>` | Registry URL, e.g. `https://free-tokens.msh.team/v1/models/api.json`. |
| `--api-key <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 <providerId>`
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 <substring>` | Case-insensitive id/name substring filter (top-level only). |
| `--url <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 <providerId>`
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 |
| --- | --- |
| `<providerId>` | Catalog provider id, e.g. `anthropic`, `openai`, `google`. |
| `--api-key <key>` | API key for the provider. Falls back to `KIMI_REGISTRY_API_KEY`. Required. |
| `--default-model <modelId>` | Optional. Set `default_model` to `<providerId>/<modelId>` after import. The model must be one listed by `catalog list <providerId>`. |
| `--url <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
```

View file

@ -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。

View file

@ -161,3 +161,90 @@ kimi migrate
```
如果你之前使用过旧版 kimi-cli可以运行此命令将历史会话、配置等数据迁移到 kimi-code 中,避免数据丢失。完整的迁移流程、迁移内容与注意事项见 [从 kimi-cli 迁移](../guides/migration.md)。
### `kimi provider`
在 shell 中管理供应商,相当于 TUI 中 `/provider` 的非交互版本。适合脚本化部署、CI 初始化、以及在新机器上一行完成配置。
```sh
kimi provider <action> [options]
```
包含三个动作:
#### `kimi provider add <url>`
从自定义 registry一份 `api.json` 文档)批量导入所有供应商。命令会拉取 registry为每个顶层条目创建 `[providers.<id>]`,为每个模型创建 `[models.<alias>]`,并在每个供应商上写入 `source = { kind = "apiJson", url, api_key }`,使下次 TUI 启动时自动刷新模型列表。
| 参数 / 选项 | 说明 |
| --- | --- |
| `<url>` | Registry 地址,例如 `https://free-tokens.msh.team/v1/models/api.json`。 |
| `--api-key <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 <providerId>`
删除指定供应商及其所有模型 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 <substring>` | 顶层视图下,按 id 或 name 大小写不敏感子串过滤。 |
| `--url <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 <providerId>`
按 id 从 catalog 直接导入一个已知供应商。协议类型、base URL、模型标识、上下文限制、能力都由 catalog 提供,你只需要提供 API key。
| 参数 / 选项 | 说明 |
| --- | --- |
| `<providerId>` | catalog 中的供应商 id例如 `anthropic``openai``google`。 |
| `--api-key <key>` | 供应商 API key。未传时回退到环境变量 `KIMI_REGISTRY_API_KEY`。必填。 |
| `--default-model <modelId>` | 可选。导入后把 `default_model` 设置为 `<providerId>/<modelId>``<modelId>` 必须在 `catalog list <providerId>` 中存在。 |
| `--url <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
```