mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
0d074f1bae
commit
3c5dee8836
9 changed files with 1601 additions and 1 deletions
5
.changeset/cli-provider-subcommand.md
Normal file
5
.changeset/cli-provider-subcommand.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
524
apps/kimi-code/src/cli/sub/provider.ts
Normal file
524
apps/kimi-code/src/cli/sub/provider.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
891
apps/kimi-code/test/cli/provider.test.ts
Normal file
891
apps/kimi-code/test/cli/provider.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue