Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/catalog-import-broader-and-safer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@moonshot-ai/kosong": patch
"@moonshot-ai/kimi-code-sdk": patch
"@moonshot-ai/kimi-code": patch
---

Import many more providers from the models.dev catalog: vendor SDKs like xai and openrouter now import instead of being refused (with a "guessed" note), deprecated and alpha models are filtered out, per-model gateway protocol and endpoint overrides are honored, and context limits are correct (input limit for compaction, total window for completion). Imports lacking a usable endpoint now ask for one via `--base-url` or a prompt.
8 changes: 8 additions & 0 deletions .changeset/thinking-levels-from-declared-capabilities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@moonshot-ai/kosong": patch
"@moonshot-ai/agent-core": patch
"@moonshot-ai/agent-core-v2": patch
"@moonshot-ai/kimi-code": patch
---

Fix thinking levels being offered for models that do not support them (e.g. phantom levels on Kimi K3): levels now come from each model's declared capabilities. Models that cannot disable reasoning (e.g. gpt-5) no longer offer an Off option, and turning thinking Off on models that support it (e.g. xai grok) now truly disables reasoning.
59 changes: 50 additions & 9 deletions apps/kimi-code/src/cli/sub/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,12 @@ import {
} from '@moonshot-ai/kimi-code-oauth';
import {
applyCatalogProvider,
catalogBaseUrl,
catalogProviderModels,
CatalogFetchError,
createKimiHarness,
DEFAULT_CATALOG_URL,
fetchCatalog,
inferWireType,
resolveCatalogImport,
type Catalog,
type CatalogProviderEntry,
type KimiConfig,
Expand Down Expand Up @@ -67,6 +66,7 @@ interface CatalogAddOptions {
readonly apiKey?: string;
readonly defaultModel?: string;
readonly url?: string;
readonly baseUrl?: string;
}

export async function handleProviderAdd(
Expand Down Expand Up @@ -276,9 +276,15 @@ export async function handleCatalogList(

for (const [id, entry] of entries) {
const modelCount = entry.models === undefined ? 0 : Object.keys(entry.models).length;
const wire = inferWireType(entry) ?? '?';
const resolution = resolveCatalogImport(entry);
const wireLabel =
resolution.kind === 'invalid'
? '?'
: resolution.guessed
? `${resolution.wire} (guessed)`
: resolution.wire;
deps.stdout.write(
`${id} wire=${wire} models=${String(modelCount)} ${entry.name ?? ''}\n`,
`${id} wire=${wireLabel} models=${String(modelCount)} ${entry.name ?? ''}\n`,
);
}
}
Expand Down Expand Up @@ -310,11 +316,37 @@ export async function handleCatalogAdd(
deps.exit(1);
}

const wire = inferWireType(entry);
if (wire === undefined) {
deps.stderr.write(`Provider "${providerId}" has an unsupported wire type in the catalog.\n`);
const resolution = resolveCatalogImport(entry, opts.baseUrl);
if (resolution.kind === 'invalid') {
switch (resolution.reason) {
case 'unknown-explicit-type':
deps.stderr.write(
`Provider "${providerId}" declares protocol "${entry.type}" in the catalog, which this client version does not support.\n`,
);
break;
case 'proprietary-sdk':
deps.stderr.write(
`Provider "${providerId}" uses a proprietary SDK this client cannot speak (e.g. Amazon Bedrock or Cohere); it cannot be imported from the catalog.\n`,
);
break;
case 'empty-base-url':
deps.stderr.write('--base-url cannot be empty.\n');
break;
case 'placeholder-base-url':
deps.stderr.write(
`Base URL "${opts.baseUrl}" contains an env placeholder. Pass --base-url with the resolved value.\n`,
);
break;
}
deps.exit(1);
}
if (resolution.kind === 'needs-base-url') {
deps.stderr.write(
`The catalog does not declare an endpoint for "${providerId}". Pass --base-url <url> (e.g. the vendor's OpenAI-compatible base URL).\n`,
);
deps.exit(1);
}
const { wire, baseUrl } = resolution;

const models = catalogProviderModels(entry);
if (models.length === 0) {
Expand Down Expand Up @@ -346,7 +378,6 @@ export async function handleCatalogAdd(
config = await harness.removeProvider(providerId);
}

const baseUrl = catalogBaseUrl(entry, wire);
// `applyCatalogProvider` always overwrites both `defaultModel` and
// `[thinking]`. The values we pass here are temporary; we restore
// a consistent state in the post-apply block below.
Expand Down Expand Up @@ -391,6 +422,11 @@ export async function handleCatalogAdd(
deps.stdout.write(
`Imported ${displayName} (${providerId}) with ${String(models.length)} model${models.length === 1 ? '' : 's'} from ${url}.\n`,
);
if (resolution.guessed) {
deps.stdout.write(
`Note: the catalog does not declare a protocol for "${providerId}"; guessed "openai". Edit "type" in config.toml if requests fail.\n`,
);
}
if (opts.defaultModel !== undefined) {
deps.stdout.write(`Default model set to ${providerId}/${opts.defaultModel}.\n`);
}
Expand Down Expand Up @@ -481,18 +517,23 @@ export function registerProviderCommand(parent: Command, deps?: Partial<Provider
.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(
'--base-url <url>',
'Override the catalog endpoint. Required when the catalog declares none (or an env placeholder).',
)
.option('--url <url>', `Override catalog URL. Defaults to ${DEFAULT_CATALOG_URL}.`)
.action(
async (
providerId: string,
options: { apiKey?: string; defaultModel?: string; url?: string },
options: { apiKey?: string; defaultModel?: string; url?: string; baseUrl?: string },
) => {
const resolved = resolveDeps(deps);
await runAction(resolved, () =>
handleCatalogAdd(resolved, providerId, {
...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }),
...(options.defaultModel === undefined ? {} : { defaultModel: options.defaultModel }),
...(options.url === undefined ? {} : { url: options.url }),
...(options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }),
}),
);
},
Expand Down
29 changes: 27 additions & 2 deletions apps/kimi-code/src/tui/commands/prompts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
catalogModelToAlias,
inferWireType,
resolveCatalogImport,
type Catalog,
type CatalogModel,
type ModelAlias,
Expand Down Expand Up @@ -128,10 +128,35 @@ export function promptApiKey(
});
}

/**
* Asks for the provider endpoint the catalog did not declare (or declared
* only as an env placeholder) — required for catalog imports whose protocol
* was guessed, where the built-in default endpoint would point at the wrong
* host. Esc cancels the import.
*/
export function promptBaseUrl(host: SlashCommandHost, platformName: string): Promise<string | undefined> {
return new Promise((resolve) => {
const dialog = new ApiKeyInputDialogComponent(
platformName,
['The catalog declares no endpoint for this provider — enter its base URL.'],
(result: ApiKeyInputResult) => {
host.restoreEditor();
resolve(result.kind === 'ok' ? result.value : undefined);
},
{
title: `Enter base URL for ${platformName}`,
mask: false,
emptyHint: 'Base URL cannot be empty.',
},
);
host.mountEditorReplacement(dialog);
});
}

export function promptCatalogProviderSelection(host: SlashCommandHost, catalog: Catalog): Promise<string | undefined> {
return new Promise((resolve) => {
const options: ChoiceOption[] = Object.entries(catalog)
.filter(([, entry]) => inferWireType(entry) !== undefined)
.filter(([, entry]) => resolveCatalogImport(entry).kind !== 'invalid')
.map(([id, entry]) => ({
value: id,
label: entry.name ?? id,
Expand Down
42 changes: 33 additions & 9 deletions apps/kimi-code/src/tui/commands/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,11 @@ import {
} from '@moonshot-ai/kimi-code-oauth';
import {
applyCatalogProvider,
catalogBaseUrl,
catalogProviderModels,
CatalogFetchError,
DEFAULT_CATALOG_URL,
fetchCatalog,
inferWireType,
resolveCatalogImport,
type Catalog,
type ThinkingEffort,
} from '@moonshot-ai/kimi-code-sdk';
Expand All @@ -33,6 +32,7 @@ import { thinkingEffortToConfig } from '../utils/thinking-config';
import { effectiveModelForHost } from './config';
import {
promptApiKey,
promptBaseUrl,
promptCatalogProviderSelection,
} from './prompts';
import type { SlashCommandHost } from './dispatch';
Expand Down Expand Up @@ -192,15 +192,34 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise<void> {
return;
}

const apiKey = await promptApiKey(host, entry.name ?? providerId);
if (apiKey === undefined) return;

const wire = inferWireType(entry);
if (wire === undefined) {
host.showError(`Provider "${providerId}" has unsupported wire type.`);
let resolution = resolveCatalogImport(entry);
if (resolution.kind === 'needs-base-url') {
const entered = await promptBaseUrl(host, entry.name ?? providerId);
if (entered === undefined) return;
resolution = resolveCatalogImport(entry, entered);
}
if (resolution.kind !== 'ok') {
if (resolution.kind === 'invalid') {
if (resolution.reason === 'unknown-explicit-type') {
host.showError(
`Provider "${providerId}" declares protocol "${entry.type}" in the catalog, which this client version does not support.`,
);
} else if (resolution.reason === 'proprietary-sdk') {
host.showError(
`Provider "${providerId}" uses a proprietary SDK this client cannot speak (e.g. Amazon Bedrock or Cohere); it cannot be imported from the catalog.`,
);
} else {
host.showError(
`Base URL contains an env placeholder or is empty. Enter the resolved URL instead.`,
);
}
}
return;
}
const baseUrl = catalogBaseUrl(entry, wire);
const { wire, baseUrl } = resolution;

const apiKey = await promptApiKey(host, entry.name ?? providerId);
if (apiKey === undefined) return;

// Persist the provider and all its models immediately after the api key is
// entered. The model selector that follows is just a convenience to pick the
Expand Down Expand Up @@ -229,6 +248,11 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise<void> {
await host.authFlow.refreshConfigAfterLogin();
host.track('connect', { provider: providerId, method: 'catalog' });
host.showStatus(`Provider added: ${entry.name ?? providerId}`);
if (resolution.guessed) {
host.showStatus(
`Protocol guessed as "openai" for ${providerId} — edit "type" in config.toml if requests fail.`,
);
}

// Build a merged model dictionary that includes existing models plus the
// newly-persisted provider's models, so the tabbed selector shows every
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,18 +48,23 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable {
private readonly onDone: (result: ApiKeyInputResult) => void;
private readonly title: string;
private readonly subtitleLines: readonly string[];
private readonly mask: boolean;
private readonly emptyHint: string;
private done = false;
private emptyHinted = false;

constructor(
platformName: string,
subtitleLines: readonly string[],
onDone: (result: ApiKeyInputResult) => void,
options?: { title?: string; mask?: boolean; emptyHint?: string },
) {
super();
this.onDone = onDone;
this.title = `Enter API key for ${platformName}`;
this.title = options?.title ?? `Enter API key for ${platformName}`;
this.subtitleLines = subtitleLines;
this.mask = options?.mask ?? true;
this.emptyHint = options?.emptyHint ?? 'API key cannot be empty.';
this.input.onSubmit = (value) => {
this.submit(value);
};
Expand Down Expand Up @@ -96,7 +101,7 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable {

const border = (s: string): string => currentTheme.fg('primary', s);
const titleStyled = currentTheme.boldFg('textStrong', this.title);
const subtitleSource = this.emptyHinted ? ['API key cannot be empty.'] : this.subtitleLines;
const subtitleSource = this.emptyHinted ? [this.emptyHint] : this.subtitleLines;
const subtitleLines = subtitleSource.map((line) =>
truncateToWidth(currentTheme.fg('textDim', line), innerWidth, '…'),
);
Expand All @@ -105,7 +110,8 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable {
const titleLine = truncateToWidth(titleStyled, innerWidth, '…');
const footerLine = truncateToWidth(footerStyled, innerWidth, '…');
const rawInputLine = this.input.render(innerWidth)[0] ?? '> ';
const inputLine = this.input.getValue() === '' ? rawInputLine : maskInputLine(rawInputLine);
const inputLine =
this.mask && this.input.getValue() !== '' ? maskInputLine(rawInputLine) : rawInputLine;

const contentLines: string[] = [
titleLine,
Expand Down
Loading
Loading