Skip to content

Commit 9ef1cc6

Browse files
committed
fix(tui): filter unsupported catalog providers
1 parent 2247086 commit 9ef1cc6

9 files changed

Lines changed: 196 additions & 26 deletions

File tree

apps/kimi-code/scripts/update-catalog.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const outFile = resolve(scriptDir, "../src/built-in-catalog.ts");
1414
const modelsUrl = process.env.MODELS_DEV_URL || "https://models.dev/api.json";
1515

1616
const KEEP_PROVIDER = new Set(["id", "name", "api", "env", "npm", "type", "models"]);
17-
const KEEP_MODEL = new Set(["id", "name", "limit", "tool_call", "reasoning", "modalities"]);
17+
const KEEP_MODEL = new Set(["id", "name", "family", "limit", "tool_call", "reasoning", "modalities"]);
1818

1919
function stripModel(model) {
2020
if (typeof model !== "object" || model === null) return undefined;

apps/kimi-code/src/built-in-catalog.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

apps/kimi-code/src/tui/kimi-tui.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ import {
4242
catalogModelToAlias,
4343
catalogProviderModels,
4444
CatalogFetchError,
45-
DEFAULT_CATALOG_URL,
4645
fetchCatalog,
4746
inferWireType,
4847
loadBuiltInCatalog,
@@ -224,6 +223,7 @@ import {
224223
import { formatBackgroundAgentTranscript } from './utils/background-agent-status';
225224
import { formatBackgroundTaskTranscript } from './utils/background-task-status';
226225
import { hasDispose, isExpandable, isPlanExpandable } from './utils/component-capabilities';
226+
import { resolveConnectCatalogRequest } from './utils/connect-catalog';
227227
import {
228228
argsRecord,
229229
formatErrorMessage,
@@ -4344,6 +4344,7 @@ export class KimiTUI {
43444344
selectedValue,
43454345
currentThinking: this.state.appState.thinking,
43464346
colors: this.state.theme.colors,
4347+
searchable: true,
43474348
onSelect: ({ alias, thinking }) => {
43484349
this.restoreEditor();
43494350
void this.performModelSwitch(alias, thinking);
@@ -5064,10 +5065,7 @@ export class KimiTUI {
50645065
// (context size, capabilities) comes from the catalog, so users do not
50655066
// hand-write it.
50665067
private async handleConnectCommand(args: string): Promise<void> {
5067-
const trimmed = args.trim();
5068-
const urlMatch = trimmed.match(/--url(?:=|\s+)(\S+)/);
5069-
const url =
5070-
urlMatch?.[1] ?? (/^https?:\/\/\S+$/.test(trimmed) ? trimmed : DEFAULT_CATALOG_URL);
5068+
const { url, allowBuiltInFallback } = resolveConnectCatalogRequest(args);
50715069

50725070
const controller = new AbortController();
50735071
const cancel = (): void => {
@@ -5085,13 +5083,18 @@ export class KimiTUI {
50855083
spinner.stop({ ok: false, label: 'Aborted.' });
50865084
} else {
50875085
const hint = error instanceof CatalogFetchError ? ` (HTTP ${error.status})` : '';
5088-
const fallback = loadBuiltInCatalog(BUILT_IN_CATALOG_JSON);
5089-
if (fallback !== undefined) {
5090-
spinner.stop({ ok: true, label: 'Using built-in catalog (offline mode).' });
5091-
catalog = fallback;
5092-
} else {
5086+
if (!allowBuiltInFallback) {
50935087
spinner.stop({ ok: false, label: 'Failed to load catalog.' });
50945088
this.showError(`Failed to fetch catalog${hint}: ${formatErrorMessage(error)}`);
5089+
} else {
5090+
const fallback = loadBuiltInCatalog(BUILT_IN_CATALOG_JSON);
5091+
if (fallback !== undefined) {
5092+
spinner.stop({ ok: true, label: 'Using built-in catalog (offline mode).' });
5093+
catalog = fallback;
5094+
} else {
5095+
spinner.stop({ ok: false, label: 'Failed to load catalog.' });
5096+
this.showError(`Failed to fetch catalog${hint}: ${formatErrorMessage(error)}`);
5097+
}
50955098
}
50965099
}
50975100
} finally {
@@ -5118,6 +5121,7 @@ export class KimiTUI {
51185121
if (apiKey === undefined) return;
51195122

51205123
const wire = inferWireType(entry);
5124+
if (wire === undefined) return;
51215125
const baseUrl = catalogBaseUrl(entry, wire);
51225126

51235127
// Remove stale provider config first so old model aliases are fully
@@ -5258,6 +5262,7 @@ export class KimiTUI {
52585262
private promptCatalogProviderSelection(catalog: Catalog): Promise<string | undefined> {
52595263
return new Promise((resolve) => {
52605264
const options: ChoiceOption[] = Object.entries(catalog)
5265+
.filter(([, entry]) => inferWireType(entry) !== undefined)
52615266
.map(([id, entry]) => ({
52625267
value: id,
52635268
label: entry.name ?? id,
@@ -5349,6 +5354,7 @@ export class KimiTUI {
53495354
currentValue: firstAlias,
53505355
currentThinking: initialThinking,
53515356
colors: this.state.theme.colors,
5357+
searchable: true,
53525358
onSelect: ({ alias, thinking }) => {
53535359
this.restoreEditor();
53545360
resolve({ alias, thinking });
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { DEFAULT_CATALOG_URL } from '@moonshot-ai/kimi-code-sdk';
2+
3+
const CATALOG_URL_FLAG_RE = /--url(?:=|\s+)(\S+)/;
4+
const BARE_HTTP_URL_RE = /^https?:\/\/\S+$/;
5+
6+
export interface ConnectCatalogRequest {
7+
readonly url: string;
8+
readonly allowBuiltInFallback: boolean;
9+
}
10+
11+
export function resolveConnectCatalogRequest(args: string): ConnectCatalogRequest {
12+
const trimmed = args.trim();
13+
const urlMatch = CATALOG_URL_FLAG_RE.exec(trimmed);
14+
const bareUrl = BARE_HTTP_URL_RE.test(trimmed) ? trimmed : undefined;
15+
const explicitUrl = urlMatch?.[1] ?? bareUrl;
16+
17+
return {
18+
url: explicitUrl ?? DEFAULT_CATALOG_URL,
19+
allowBuiltInFallback: explicitUrl === undefined,
20+
};
21+
}

apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,21 @@ interface FeedbackDriver extends MessageDriver {
3434
promptFeedbackInput(): Promise<string | undefined>;
3535
}
3636

37+
interface ModelSelectorDriver extends MessageDriver {
38+
runModelSelector(
39+
models: Record<
40+
string,
41+
{
42+
provider: string;
43+
model: string;
44+
maxContextSize: number;
45+
displayName?: string;
46+
capabilities?: string[];
47+
}
48+
>,
49+
): Promise<{ alias: string; thinking: boolean } | undefined>;
50+
}
51+
3752
function makeStartupInput(): KimiTUIStartupInput {
3853
return {
3954
cliOptions: {
@@ -1017,6 +1032,12 @@ describe('KimiTUI message flow', () => {
10171032
const pickerOutput = stripSgr((picker as ModelSelectorComponent).render(120).join('\n'));
10181033
expect(pickerOutput).toContain('Kimi K2 (Kimi Code) ← current');
10191034
expect(pickerOutput).toContain('❯ Kimi Turbo (Kimi Code)');
1035+
(picker as ModelSelectorComponent).handleInput('t');
1036+
(picker as ModelSelectorComponent).handleInput('u');
1037+
const filteredOutput = stripSgr((picker as ModelSelectorComponent).render(120).join('\n'));
1038+
expect(filteredOutput).toContain('Search: tu');
1039+
expect(filteredOutput).toContain('Kimi Turbo (Kimi Code)');
1040+
expect(filteredOutput).not.toContain('Kimi K2 (Kimi Code)');
10201041
(picker as ModelSelectorComponent).handleInput('\u001B[D');
10211042
(picker as ModelSelectorComponent).handleInput('\r');
10221043

@@ -1028,6 +1049,41 @@ describe('KimiTUI message flow', () => {
10281049
expect(driver.state.appState.thinking).toBe(true);
10291050
});
10301051

1052+
it('enables search in the shared model selector helper', async () => {
1053+
const { driver } = await makeDriver();
1054+
const selectorDriver = driver as unknown as ModelSelectorDriver;
1055+
const selection = selectorDriver.runModelSelector({
1056+
alpha: {
1057+
provider: 'managed:kimi-code',
1058+
model: 'kimi-alpha',
1059+
maxContextSize: 100,
1060+
displayName: 'Kimi Alpha',
1061+
capabilities: ['thinking'],
1062+
},
1063+
turbo: {
1064+
provider: 'managed:kimi-code',
1065+
model: 'kimi-turbo',
1066+
maxContextSize: 100,
1067+
displayName: 'Kimi Turbo',
1068+
capabilities: ['thinking'],
1069+
},
1070+
});
1071+
1072+
const picker = driver.state.editorContainer.children[0];
1073+
expect(picker).toBeInstanceOf(ModelSelectorComponent);
1074+
(picker as ModelSelectorComponent).handleInput('t');
1075+
(picker as ModelSelectorComponent).handleInput('u');
1076+
1077+
const output = stripSgr((picker as ModelSelectorComponent).render(120).join('\n'));
1078+
expect(output).toContain('Search: tu');
1079+
expect(output).toContain('Kimi Turbo (Kimi Code)');
1080+
expect(output).not.toContain('Kimi Alpha (Kimi Code)');
1081+
1082+
(picker as ModelSelectorComponent).handleInput('\u001B');
1083+
(picker as ModelSelectorComponent).handleInput('\u001B');
1084+
await expect(selection).resolves.toBeUndefined();
1085+
});
1086+
10311087
it('deletes Kitty inline images when /new clears the transcript', async () => {
10321088
setCapabilities({ images: 'kitty', trueColor: true, hyperlinks: true });
10331089
const { driver, harness } = await makeDriver(makeSession({ id: 'ses-1' }));
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { DEFAULT_CATALOG_URL } from '@moonshot-ai/kimi-code-sdk';
2+
import { describe, expect, it } from 'vitest';
3+
4+
import { resolveConnectCatalogRequest } from '#/tui/utils/connect-catalog';
5+
6+
describe('resolveConnectCatalogRequest', () => {
7+
it('uses the default catalog and permits built-in fallback when no URL is specified', () => {
8+
expect(resolveConnectCatalogRequest('')).toEqual({
9+
url: DEFAULT_CATALOG_URL,
10+
allowBuiltInFallback: true,
11+
});
12+
expect(resolveConnectCatalogRequest('ignored text')).toEqual({
13+
url: DEFAULT_CATALOG_URL,
14+
allowBuiltInFallback: true,
15+
});
16+
});
17+
18+
it('treats explicit catalog URLs as authoritative', () => {
19+
expect(resolveConnectCatalogRequest('--url=https://internal.example/catalog.json')).toEqual({
20+
url: 'https://internal.example/catalog.json',
21+
allowBuiltInFallback: false,
22+
});
23+
expect(resolveConnectCatalogRequest('--url https://internal.example/catalog.json')).toEqual({
24+
url: 'https://internal.example/catalog.json',
25+
allowBuiltInFallback: false,
26+
});
27+
expect(resolveConnectCatalogRequest('https://internal.example/catalog.json')).toEqual({
28+
url: 'https://internal.example/catalog.json',
29+
allowBuiltInFallback: false,
30+
});
31+
});
32+
});

packages/kosong/src/catalog.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { ProviderType } from './providers';
99
export interface CatalogModelEntry {
1010
readonly id?: string;
1111
readonly name?: string;
12+
readonly family?: string;
1213
readonly limit?: { readonly context?: number; readonly output?: number };
1314
readonly tool_call?: boolean;
1415
readonly reasoning?: boolean;
@@ -56,12 +57,28 @@ function isWireType(value: unknown): value is ProviderType {
5657
return typeof value === 'string' && (KNOWN_WIRE_TYPES as readonly string[]).includes(value);
5758
}
5859

60+
function hasEmbeddingMarker(value: string | undefined): boolean {
61+
if (value === undefined) return false;
62+
const lower = value.toLowerCase();
63+
return lower.includes('embedding') || /(?:^|[-_/])embed(?:$|[-_/])/.test(lower);
64+
}
65+
66+
function isUsableChatModel(model: CatalogModelEntry): boolean {
67+
const outputModalities = model.modalities?.output;
68+
if (outputModalities !== undefined && !outputModalities.includes('text')) return false;
69+
return (
70+
!hasEmbeddingMarker(model.family) &&
71+
!hasEmbeddingMarker(model.id) &&
72+
!hasEmbeddingMarker(model.name)
73+
);
74+
}
75+
5976
/**
60-
* Resolves a catalog provider entry to a wire type. Honors an explicit `type`,
61-
* otherwise infers from `npm`/`id`. The openai vs openai_responses split is
62-
* per-model, so provider-level inference resolves OpenAI-compatible to `openai`.
77+
* Resolves a catalog provider entry to a supported wire type. Honors an
78+
* explicit `type`, otherwise infers from `npm`/`id`. Unknown providers return
79+
* `undefined` so callers can omit them instead of writing an invalid config.
6380
*/
64-
export function inferWireType(entry: CatalogProviderEntry): ProviderType {
81+
export function inferWireType(entry: CatalogProviderEntry): ProviderType | undefined {
6582
if (isWireType(entry.type)) return entry.type;
6683
const npm = (entry.npm ?? '').toLowerCase();
6784
const id = (entry.id ?? '').toLowerCase();
@@ -72,7 +89,8 @@ export function inferWireType(entry: CatalogProviderEntry): ProviderType {
7289
if (npm.includes('google') || id.includes('google') || id.includes('gemini')) {
7390
return 'google-genai';
7491
}
75-
return 'openai';
92+
if (npm.includes('openai') || id.includes('openai')) return 'openai';
93+
return undefined;
7694
}
7795

7896
/**
@@ -101,6 +119,7 @@ export function catalogModelToCapability(model: CatalogModelEntry): CatalogModel
101119
if (typeof model.id !== 'string' || model.id.length === 0) return undefined;
102120
const context = model.limit?.context;
103121
if (typeof context !== 'number' || !Number.isInteger(context) || context <= 0) return undefined;
122+
if (!isUsableChatModel(model)) return undefined;
104123
const inputs = model.modalities?.input ?? [];
105124
const output = model.limit?.output;
106125
return {

packages/kosong/test/catalog.test.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ describe('inferWireType', () => {
2222
expect(inferWireType({ id: 'google-vertex' })).toBe('vertexai');
2323
});
2424

25-
it('falls back to openai for unknown / invalid type', () => {
26-
expect(inferWireType({ id: 'some-proxy' })).toBe('openai');
27-
expect(inferWireType({ id: 'x', type: 'not-a-wire' })).toBe('openai');
25+
it('returns undefined for unknown / invalid wire types', () => {
26+
expect(inferWireType({ id: 'some-proxy' })).toBeUndefined();
27+
expect(inferWireType({ id: 'x', type: 'not-a-wire' })).toBeUndefined();
2828
});
2929
});
3030

@@ -92,6 +92,36 @@ describe('catalogModelToCapability', () => {
9292
expect(catalogModelToCapability({ id: 'm' })).toBeUndefined();
9393
expect(catalogModelToCapability({ id: 'm', limit: { context: 0 } })).toBeUndefined();
9494
});
95+
96+
it('skips embedding and non-text-output models that cannot serve as chat defaults', () => {
97+
expect(
98+
catalogModelToCapability({
99+
id: 'text-embedding-3-large',
100+
name: 'text-embedding-3-large',
101+
family: 'text-embedding',
102+
limit: { context: 8192, output: 1536 },
103+
modalities: { input: ['text'], output: ['text'] },
104+
}),
105+
).toBeUndefined();
106+
expect(
107+
catalogModelToCapability({
108+
id: 'grok-imagine-image',
109+
name: 'Grok Imagine Image',
110+
family: 'grok',
111+
limit: { context: 8000 },
112+
modalities: { input: ['text', 'image'], output: ['image', 'pdf'] },
113+
}),
114+
).toBeUndefined();
115+
expect(
116+
catalogModelToCapability({
117+
id: 'mimo-v2-tts',
118+
name: 'MiMo-V2-TTS',
119+
family: 'mimo',
120+
limit: { context: 8192, output: 16384 },
121+
modalities: { input: ['text'], output: ['audio'] },
122+
}),
123+
).toBeUndefined();
124+
});
95125
});
96126

97127
describe('catalogProviderModels', () => {

packages/node-sdk/src/catalog.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,6 @@ export interface ApplyCatalogProviderOptions {
7272
readonly thinking: boolean;
7373
}
7474

75-
/**
76-
* Writes a catalog-selected provider and its model aliases into config,
77-
* replacing any stale aliases that belonged to the same provider. Model
78-
* metadata (context window, output limit, capabilities) comes from the
79-
* catalog, so the user does not hand-write it. Returns the default model key.
80-
*/
8175
/**
8276
* Parses an optional pruned models.dev catalog string — typically the
8377
* `__KIMI_CODE_BUILT_IN_CATALOG__` constant injected by tsdown at build
@@ -92,6 +86,18 @@ export function loadBuiltInCatalog(text?: string): Catalog | undefined {
9286
}
9387
}
9488

89+
/**
90+
* Writes a catalog-selected provider and its model aliases into `config` and
91+
* marks it the default. Model metadata (context, output limit, capabilities)
92+
* comes from the catalog, so the user does not hand-write it. Returns the
93+
* default model key.
94+
*
95+
* NOTE: the same-provider cleanup below mutates the passed-in `config` only.
96+
* It clears stale aliases on disk solely when the caller overwrites the whole
97+
* config. Callers persisting via `setConfig` — a deep-merge patch that cannot
98+
* delete keys — must call `removeProvider` first, or removed aliases reappear
99+
* after the merge.
100+
*/
95101
export function applyCatalogProvider(
96102
config: KimiConfig,
97103
options: ApplyCatalogProviderOptions,

0 commit comments

Comments
 (0)