mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
feat(kimi-code): add /connect command with bundled model catalog (#30)
* feat: add /connect command with models.dev catalog support Add a /connect slash command that configures a provider and model from a models.dev-style catalog. Users no longer need to hand-write model metadata (context window, output limit, capabilities). Architecture (3 layers): - kosong: pure data layer — Catalog schema, inferWireType, catalogModelToCapability - node-sdk: IO + config write — fetchCatalog, applyCatalogProvider, catalogModelToAlias - app: TUI flow — /connect command, provider/model selection, credential input, config persistence UI improvements in this PR: - ChoicePickerComponent: add searchable (fuzzy filter + search bar) - ModelSelectorComponent: add searchable (same) - Extract reusable paging.ts for list pagination Changesets included for kosong, kimi-code-sdk, and kimi-code. * feat: bundle pruned models.dev catalog for offline /connect When the network is unavailable, /connect now falls back to a built-in snapshot of the models.dev catalog. - `scripts/update-catalog.mjs`: fetches models.dev/api.json, strips unnecessary fields, and writes `src/built-in-catalog.ts` with the JSON string as a TS constant. - `loadBuiltInCatalog(text?)` in node-sdk: parses the JSON string safely; returns undefined on any failure. - `handleConnectCommand`: on fetch failure, shows an informative offline message and tries the built-in snapshot. - The snapshot file is a placeholder (`undefined`) in source control; `update-catalog.mjs` populates it before release builds so the actual catalog is inlined into the bundle by rolldown. * refactor(tui): share search and pagination across list pickers ChoicePicker and ModelSelector each carried their own copy of the cursor + fuzzy-search + pagination state machine. Extract it into a reusable SearchableList so both pickers share one implementation; behavior is unchanged. * fix(tui): filter unsupported catalog providers * docs(tui): clarify /connect stale-alias cleanup depends on removeProvider * fix(kimi-code): inject built-in catalog at release time * feat(tui): hint at /login and /connect when /model has no models Replace the bare "No models configured." error with a notice that points users to /login for Kimi and /connect for other providers. * fix(tui): tighten /connect error reporting for edge cases Two silent-failure cases in /connect could leave users without any feedback to act on: - Reject `--url` when its value is missing (e.g. `/connect --url` or `/connect --url=`). Previously the argument parser silently fell back to the default catalog, so a malformed flag still appeared to succeed but with the wrong source. - Show an explicit error when the resolved catalog yields no providers with supported wire types. Previously the picker resolved with no selection and the command returned without any UI feedback. * chore(tui): restore slash invalid intent type * fix(tui): support /logout for /connect-configured providers After /connect writes a non-managed provider (e.g. openai), /logout fell through to "Nothing to logout." because the handler only matched the managed default and isOpenPlatformId branches, leaving users no in-app way to drop the API key and model aliases they just configured. Collapse the OpenPlatform branch into a generic "provider is present in config" check so any non-managed provider in config — OpenPlatform OAuth targets and /connect catalog providers — goes through the same removeProvider path. * fix(tui): reject --url values that are not http(s) URLs `resolveConnectCatalogRequest` previously matched any non-space token after `--url` as the URL, so `/connect --url --refresh` parsed `--refresh` as the value and bypassed the missing-value error path. Bare non-URL tokens (`/connect --url not-a-url`) and non-http(s) schemes were also silently accepted. Constrain the captured value to `https?://...` so flag-like and non-URL tokens fall through to the existing `URL_FLAG_PRESENT_RE` check and surface a clear error. * ci(native): scope built-in catalog generation to signed macOS jobs The catalog-generation step ran whenever `inputs.sign-macos` was true, including Linux and Windows targets that take the local-profile build path and never consume the generated catalog. A transient models.dev outage would therefore fail unrelated artifact builds. Match the condition to the macOS signed release-profile build that actually consumes the bundled catalog. * fix(ci): embed built-in catalog in non-macOS native artifacts The earlier narrowing to `runner.os == 'macOS' && inputs.sign-macos` relied on a misread of build.mjs: its `profile === 'release'` guard only auto-fetches the catalog as a dev fallback. Whether the binary actually embeds the catalog is decided by tsdown's define at bundle time, which reads KIMI_CODE_BUILT_IN_CATALOG_FILE regardless of profile. Linux and Windows release artifacts therefore lost their bundled catalog and silently regressed offline /connect on those targets. Restore generation for all OS jobs when sign-macos is true. * chore(tui): mention /connect in welcome panel hints Align the welcome panel with the /model picker so the empty-state copy points users to both /login and /connect.
This commit is contained in:
parent
e9e4a48633
commit
a200a297ac
34 changed files with 1801 additions and 69 deletions
7
.changeset/connect-model-catalog.md
Normal file
7
.changeset/connect-model-catalog.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@moonshot-ai/kosong": minor
|
||||
"@moonshot-ai/kimi-code-sdk": minor
|
||||
"@moonshot-ai/kimi-code": minor
|
||||
---
|
||||
|
||||
Add a `/connect` command that configures a provider and model from a model catalog. By default it reads from a pruned catalog snapshot bundled with the CLI, so the command works offline and is not gated by models.dev availability. Model metadata (context window, output limit, and capabilities) is filled in automatically, so models no longer need to be written by hand in config. Pass `--refresh` to fetch the latest catalog from models.dev (falling back to the bundled snapshot on failure), or `--url` to point at a custom catalog endpoint that uses the same format. When connecting an Anthropic-compatible provider whose catalog base URL already includes a version segment, the request path no longer duplicates that segment, so connections that previously failed with a not-found error now succeed.
|
||||
5
.changeset/connect-picker-search-pagination.md
Normal file
5
.changeset/connect-picker-search-pagination.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": minor
|
||||
---
|
||||
|
||||
The `/connect` provider and model pickers now support type-to-search filtering, and long selection lists are paginated instead of rendering every entry at once. The model picker also paginates when many models are configured.
|
||||
5
.changeset/model-picker-empty-hint.md
Normal file
5
.changeset/model-picker-empty-hint.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Show a hint pointing to /login (Kimi) and /connect (other providers) when /model is opened with no configured models, and surface the same hint on the welcome panel when no model is set.
|
||||
8
.github/workflows/_native-build.yml
vendored
8
.github/workflows/_native-build.yml
vendored
|
|
@ -77,6 +77,14 @@ jobs:
|
|||
certificate-p12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
|
||||
certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: Generate built-in catalog (release artifacts)
|
||||
if: inputs.sign-macos
|
||||
shell: bash
|
||||
run: |
|
||||
CATALOG_FILE="$RUNNER_TEMP/kimi-code-built-in-catalog.json"
|
||||
node apps/kimi-code/scripts/update-catalog.mjs --out "$CATALOG_FILE"
|
||||
echo "KIMI_CODE_BUILT_IN_CATALOG_FILE=$CATALOG_FILE" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build native executable (release profile, macOS signed)
|
||||
if: runner.os == 'macOS' && inputs.sign-macos
|
||||
run: pnpm --filter @moonshot-ai/kimi-code run build:native:release
|
||||
|
|
|
|||
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
|
|
@ -41,6 +41,13 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate Kimi Code built-in catalog
|
||||
shell: bash
|
||||
run: |
|
||||
CATALOG_FILE="$RUNNER_TEMP/kimi-code-built-in-catalog.json"
|
||||
node apps/kimi-code/scripts/update-catalog.mjs --out "$CATALOG_FILE"
|
||||
echo "KIMI_CODE_BUILT_IN_CATALOG_FILE=$CATALOG_FILE" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build packages
|
||||
run: pnpm build
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@
|
|||
},
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"catalog:update": "node scripts/update-catalog.mjs --out dist/built-in-catalog.json",
|
||||
"smoke": "node scripts/smoke.mjs",
|
||||
"build:native:js": "node scripts/native/01-bundle.mjs",
|
||||
"build:native:sea": "node scripts/native/build.mjs --profile=local",
|
||||
|
|
|
|||
10
apps/kimi-code/scripts/built-in-catalog.mjs
Normal file
10
apps/kimi-code/scripts/built-in-catalog.mjs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
|
||||
export const BUILT_IN_CATALOG_ENV = 'KIMI_CODE_BUILT_IN_CATALOG_FILE';
|
||||
export const BUILT_IN_CATALOG_DEFINE = '__KIMI_CODE_BUILT_IN_CATALOG__';
|
||||
|
||||
export function builtInCatalogDefine(env = process.env) {
|
||||
const file = env[BUILT_IN_CATALOG_ENV];
|
||||
if (file === undefined || file.length === 0) return 'undefined';
|
||||
return JSON.stringify(readFileSync(file, 'utf-8'));
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { resolve } from 'node:path';
|
||||
import { parseArgs } from 'node:util';
|
||||
|
||||
import { runBundleStep } from './01-bundle.mjs';
|
||||
|
|
@ -5,6 +6,9 @@ import { runInjectStep } from './03-inject.mjs';
|
|||
import { runSeaBlobStep } from './02-sea-blob.mjs';
|
||||
import { runSignStep } from './04-sign.mjs';
|
||||
import { runVerifyStep } from './05-verify.mjs';
|
||||
import { run } from './exec.mjs';
|
||||
import { appRoot, nativeIntermediatesDir } from './paths.mjs';
|
||||
import { BUILT_IN_CATALOG_ENV } from '../built-in-catalog.mjs';
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
|
|
@ -31,6 +35,12 @@ function ensureNodeVersion() {
|
|||
ensureNodeVersion();
|
||||
console.log(`==> Native build (profile=${profile})`);
|
||||
|
||||
if (profile === 'release' && process.env[BUILT_IN_CATALOG_ENV] === undefined) {
|
||||
const catalogPath = resolve(nativeIntermediatesDir(), 'built-in-catalog.json');
|
||||
await run(process.execPath, [resolve(appRoot, 'scripts/update-catalog.mjs'), '--out', catalogPath]);
|
||||
process.env[BUILT_IN_CATALOG_ENV] = catalogPath;
|
||||
}
|
||||
|
||||
await runBundleStep();
|
||||
await runSeaBlobStep();
|
||||
await runInjectStep();
|
||||
|
|
|
|||
87
apps/kimi-code/scripts/update-catalog.mjs
Normal file
87
apps/kimi-code/scripts/update-catalog.mjs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fetches models.dev/api.json, strips fields not needed by kimi-code, and
|
||||
* writes the result as raw JSON for release builds to inline.
|
||||
*
|
||||
* This script intentionally does not write into src/. The source tree keeps a
|
||||
* placeholder so the generated catalog is not committed.
|
||||
*/
|
||||
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const scriptDir = import.meta.dirname;
|
||||
const outFile = resolveOutputFile(process.argv.slice(2));
|
||||
const modelsUrl = process.env.MODELS_DEV_URL || "https://models.dev/api.json";
|
||||
|
||||
const KEEP_PROVIDER = new Set(["id", "name", "api", "env", "npm", "type", "models"]);
|
||||
const KEEP_MODEL = new Set(["id", "name", "family", "limit", "tool_call", "reasoning", "modalities"]);
|
||||
|
||||
function resolveOutputFile(args) {
|
||||
const index = args.indexOf("--out");
|
||||
if (index !== -1) {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.length === 0) {
|
||||
throw new Error("Missing value for --out");
|
||||
}
|
||||
return resolve(process.cwd(), value);
|
||||
}
|
||||
return resolve(scriptDir, "../dist/built-in-catalog.json");
|
||||
}
|
||||
|
||||
function stripModel(model) {
|
||||
if (typeof model !== "object" || model === null) return undefined;
|
||||
const result = {};
|
||||
for (const key of Object.keys(model)) {
|
||||
if (KEEP_MODEL.has(key)) result[key] = model[key];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function stripProvider(provider) {
|
||||
if (typeof provider !== "object" || provider === null) return undefined;
|
||||
const result = {};
|
||||
for (const key of Object.keys(provider)) {
|
||||
if (!KEEP_PROVIDER.has(key)) continue;
|
||||
const value = provider[key];
|
||||
if (key === "models") {
|
||||
const stripped = {};
|
||||
for (const [mId, m] of Object.entries(value)) {
|
||||
const s = stripModel(m);
|
||||
if (s !== undefined) stripped[mId] = s;
|
||||
}
|
||||
if (Object.keys(stripped).length > 0) result[key] = stripped;
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function fetchCatalog(url) {
|
||||
const res = await fetch(url, { headers: { Accept: "application/json" } });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const raw = await res.json();
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
||||
throw new Error("invalid payload shape");
|
||||
}
|
||||
const stripped = {};
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
const p = stripProvider(v);
|
||||
if (p !== undefined && Object.keys(p).length > 0) stripped[k] = p;
|
||||
}
|
||||
return JSON.stringify(stripped);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Fetching ${modelsUrl} ...`);
|
||||
const json = await fetchCatalog(modelsUrl);
|
||||
mkdirSync(dirname(outFile), { recursive: true });
|
||||
writeFileSync(outFile, json, "utf-8");
|
||||
console.log(`Wrote ${outFile} (${(json.length / 1024).toFixed(0)} KB JSON)`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
8
apps/kimi-code/src/built-in-catalog.ts
Normal file
8
apps/kimi-code/src/built-in-catalog.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Filled by tsdown define in release builds. Source stays empty so the
|
||||
// generated models.dev snapshot is not committed.
|
||||
declare const __KIMI_CODE_BUILT_IN_CATALOG__: string | undefined;
|
||||
|
||||
export const BUILT_IN_CATALOG_JSON: string | undefined =
|
||||
typeof __KIMI_CODE_BUILT_IN_CATALOG__ === 'string'
|
||||
? __KIMI_CODE_BUILT_IN_CATALOG__
|
||||
: undefined;
|
||||
|
|
@ -141,6 +141,12 @@ export const BUILTIN_SLASH_COMMANDS = [
|
|||
description: 'Select a platform and authenticate',
|
||||
priority: 40,
|
||||
},
|
||||
{
|
||||
name: 'connect',
|
||||
aliases: [],
|
||||
description: 'Connect a provider from a model catalog',
|
||||
priority: 40,
|
||||
},
|
||||
{
|
||||
name: 'exit',
|
||||
aliases: ['quit', 'q'],
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export class WelcomeComponent implements Component {
|
|||
const dim = chalk.hex(this.colors.textDim);
|
||||
const labelStyle = chalk.bold.hex(this.colors.textDim);
|
||||
const rightRow1 = truncateToWidth(
|
||||
dim(isLoggedOut ? 'Run /login to sign in.' : 'Send /help for help information.'),
|
||||
dim(isLoggedOut ? 'Run /login or /connect to get started.' : 'Send /help for help information.'),
|
||||
textWidth,
|
||||
'…',
|
||||
);
|
||||
|
|
@ -52,7 +52,7 @@ export class WelcomeComponent implements Component {
|
|||
];
|
||||
|
||||
const modelValue = isLoggedOut
|
||||
? chalk.hex(this.colors.warning)('not set, send /login to login')
|
||||
? chalk.hex(this.colors.warning)('not set, run /login or /connect')
|
||||
: this.state.model;
|
||||
|
||||
const infoLines = [
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
import chalk from 'chalk';
|
||||
|
||||
import type { ColorPalette } from '#/tui/theme/colors';
|
||||
import { SearchableList } from '#/tui/utils/searchable-list';
|
||||
|
||||
export interface ChoiceOption {
|
||||
/** Value passed to onSelect (e.g. the actual editor command string). */
|
||||
|
|
@ -35,6 +36,10 @@ export interface ChoicePickerOptions {
|
|||
readonly options: readonly ChoiceOption[];
|
||||
readonly currentValue?: string;
|
||||
readonly colors: ColorPalette;
|
||||
/** When true, typed characters filter the list (fuzzy) and a search line is shown. */
|
||||
readonly searchable?: boolean;
|
||||
/** Items per page. Lists longer than this paginate. */
|
||||
readonly pageSize?: number;
|
||||
readonly onSelect: (value: string) => void;
|
||||
readonly onCancel: () => void;
|
||||
}
|
||||
|
|
@ -67,48 +72,73 @@ function wrapDescription(text: string, width: number): string[] {
|
|||
export class ChoicePickerComponent extends Container implements Focusable {
|
||||
focused = false;
|
||||
private readonly opts: ChoicePickerOptions;
|
||||
private selectedIndex: number;
|
||||
private readonly list: SearchableList<ChoiceOption>;
|
||||
|
||||
constructor(opts: ChoicePickerOptions) {
|
||||
super();
|
||||
this.opts = opts;
|
||||
const currentIdx = opts.options.findIndex((o) => o.value === opts.currentValue);
|
||||
this.selectedIndex = Math.max(currentIdx, 0);
|
||||
this.list = new SearchableList({
|
||||
items: opts.options,
|
||||
toSearchText: (o) => `${o.label} ${o.description ?? ''}`,
|
||||
pageSize: opts.pageSize,
|
||||
initialIndex: Math.max(currentIdx, 0),
|
||||
searchable: opts.searchable === true,
|
||||
});
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
if (this.list.clearQuery()) return;
|
||||
this.opts.onCancel();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.up)) {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||
// Left/Right page through the list (this picker has no horizontal control).
|
||||
if (matchesKey(data, Key.left)) {
|
||||
this.list.pageUp();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.down)) {
|
||||
this.selectedIndex = Math.min(this.opts.options.length - 1, this.selectedIndex + 1);
|
||||
if (matchesKey(data, Key.right)) {
|
||||
this.list.pageDown();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.enter)) {
|
||||
const chosen = this.opts.options[this.selectedIndex];
|
||||
const chosen = this.list.selected();
|
||||
if (chosen !== undefined) this.opts.onSelect(chosen.value);
|
||||
return;
|
||||
}
|
||||
this.list.handleKey(data);
|
||||
}
|
||||
|
||||
override render(width: number): string[] {
|
||||
const { colors } = this.opts;
|
||||
const hint = this.opts.hint ?? '↑↓ navigate · Enter select · Esc cancel';
|
||||
const searchable = this.opts.searchable === true;
|
||||
const view = this.list.view();
|
||||
const options = view.items;
|
||||
|
||||
const navParts = ['↑↓ navigate'];
|
||||
if (view.page.pageCount > 1) navParts.push('←→ page');
|
||||
navParts.push('Enter select', 'Esc cancel');
|
||||
const hint = this.opts.hint ?? navParts.join(' · ');
|
||||
|
||||
const titleSuffix =
|
||||
searchable && view.query.length === 0 ? chalk.hex(colors.textMuted)(' (type to search)') : '';
|
||||
const lines: string[] = [
|
||||
chalk.hex(colors.primary)('─'.repeat(width)),
|
||||
chalk.hex(colors.primary).bold(` ${this.opts.title}`),
|
||||
chalk.hex(colors.textMuted)(` ${hint}`),
|
||||
'',
|
||||
chalk.hex(colors.primary).bold(` ${this.opts.title}`) + titleSuffix,
|
||||
];
|
||||
if (searchable && view.query.length > 0) {
|
||||
lines.push(chalk.hex(colors.primary)(` Search: `) + chalk.hex(colors.text)(view.query));
|
||||
}
|
||||
lines.push(chalk.hex(colors.textMuted)(` ${hint}`));
|
||||
lines.push('');
|
||||
|
||||
for (let i = 0; i < this.opts.options.length; i++) {
|
||||
const opt = this.opts.options[i]!;
|
||||
const isSelected = i === this.selectedIndex;
|
||||
if (options.length === 0) {
|
||||
lines.push(chalk.hex(colors.textMuted)(' No matches'));
|
||||
}
|
||||
for (let i = view.page.start; i < view.page.end; i++) {
|
||||
const opt = options[i]!;
|
||||
const isSelected = i === view.selectedIndex;
|
||||
const isCurrent = opt.value === this.opts.currentValue;
|
||||
const pointer = isSelected ? '❯' : ' ';
|
||||
const labelStyle = isSelected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text);
|
||||
|
|
@ -127,6 +157,13 @@ export class ChoicePickerComponent extends Container implements Focusable {
|
|||
}
|
||||
|
||||
lines.push('');
|
||||
if (view.page.pageCount > 1) {
|
||||
lines.push(
|
||||
chalk.hex(colors.textMuted)(
|
||||
` Page ${String(view.page.page + 1)}/${String(view.page.pageCount)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
lines.push(chalk.hex(colors.primary)('─'.repeat(width)));
|
||||
return lines.map((line) => truncateToWidth(line, width));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import chalk from 'chalk';
|
|||
|
||||
import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '#/constant/app';
|
||||
import type { ColorPalette } from '#/tui/theme/colors';
|
||||
import { SearchableList } from '#/tui/utils/searchable-list';
|
||||
|
||||
import type { ChoiceOption } from './choice-picker';
|
||||
|
||||
|
|
@ -51,6 +52,10 @@ export interface ModelSelectorOptions {
|
|||
readonly selectedValue?: string;
|
||||
readonly currentThinking: boolean;
|
||||
readonly colors: ColorPalette;
|
||||
/** When true, typed characters filter the list (fuzzy) and a search line is shown. */
|
||||
readonly searchable?: boolean;
|
||||
/** Items per page. Lists longer than this paginate (PgUp/PgDn). */
|
||||
readonly pageSize?: number;
|
||||
readonly onSelect: (selection: ModelSelection) => void;
|
||||
readonly onCancel: () => void;
|
||||
}
|
||||
|
|
@ -80,34 +85,34 @@ function effectiveThinking(model: ModelAlias, thinkingDraft: boolean): boolean {
|
|||
export class ModelSelectorComponent extends Container implements Focusable {
|
||||
focused = false;
|
||||
private readonly opts: ModelSelectorOptions;
|
||||
private readonly choices: readonly ModelChoice[];
|
||||
private selectedIndex: number;
|
||||
private readonly list: SearchableList<ModelChoice>;
|
||||
private thinkingDraft: boolean;
|
||||
|
||||
constructor(opts: ModelSelectorOptions) {
|
||||
super();
|
||||
this.opts = opts;
|
||||
this.choices = createModelChoices(opts.models);
|
||||
const choices = createModelChoices(opts.models);
|
||||
const selectedValue = opts.selectedValue ?? opts.currentValue;
|
||||
const selectedIdx = this.choices.findIndex((choice) => choice.alias === selectedValue);
|
||||
this.selectedIndex = Math.max(selectedIdx, 0);
|
||||
const selectedIdx = choices.findIndex((choice) => choice.alias === selectedValue);
|
||||
this.list = new SearchableList({
|
||||
items: choices,
|
||||
toSearchText: (c) => c.label,
|
||||
pageSize: opts.pageSize,
|
||||
initialIndex: Math.max(selectedIdx, 0),
|
||||
searchable: opts.searchable === true,
|
||||
});
|
||||
this.thinkingDraft = opts.currentThinking;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
if (this.list.clearQuery()) return;
|
||||
this.opts.onCancel();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.up)) {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.down)) {
|
||||
this.selectedIndex = Math.max(0, Math.min(this.choices.length - 1, this.selectedIndex + 1));
|
||||
return;
|
||||
}
|
||||
const selected = this.selectedChoice();
|
||||
const selected = this.list.selected();
|
||||
// Left/Right toggle thinking (only when the model supports it); paging is on
|
||||
// PgUp/PgDn so the horizontal arrows stay free for the thinking control.
|
||||
if (selected !== undefined && thinkingAvailability(selected.model) === 'toggle') {
|
||||
if (matchesKey(data, Key.left)) {
|
||||
this.thinkingDraft = true;
|
||||
|
|
@ -124,21 +129,39 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
|||
alias: selected.alias,
|
||||
thinking: effectiveThinking(selected.model, this.thinkingDraft),
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.list.handleKey(data);
|
||||
}
|
||||
|
||||
override render(width: number): string[] {
|
||||
const { colors } = this.opts;
|
||||
const searchable = this.opts.searchable === true;
|
||||
const view = this.list.view();
|
||||
const choices = view.items;
|
||||
|
||||
const navParts = ['↑↓ model', '←→ thinking'];
|
||||
if (view.page.pageCount > 1) navParts.push('PgUp/PgDn page');
|
||||
navParts.push('Enter apply', 'Esc cancel');
|
||||
|
||||
const titleSuffix =
|
||||
searchable && view.query.length === 0 ? chalk.hex(colors.textMuted)(' (type to search)') : '';
|
||||
const lines: string[] = [
|
||||
chalk.hex(colors.primary)('─'.repeat(width)),
|
||||
chalk.hex(colors.primary).bold(' Select a model'),
|
||||
chalk.hex(colors.textMuted)(' ↑↓ model · ←→ thinking · Enter apply · Esc cancel'),
|
||||
'',
|
||||
chalk.hex(colors.primary).bold(' Select a model') + titleSuffix,
|
||||
];
|
||||
if (searchable && view.query.length > 0) {
|
||||
lines.push(chalk.hex(colors.primary)(' Search: ') + chalk.hex(colors.text)(view.query));
|
||||
}
|
||||
lines.push(chalk.hex(colors.textMuted)(` ${navParts.join(' · ')}`));
|
||||
lines.push('');
|
||||
|
||||
for (let i = 0; i < this.choices.length; i++) {
|
||||
const choice = this.choices[i]!;
|
||||
const isSelected = i === this.selectedIndex;
|
||||
if (choices.length === 0) {
|
||||
lines.push(chalk.hex(colors.textMuted)(' No matches'));
|
||||
}
|
||||
for (let i = view.page.start; i < view.page.end; i++) {
|
||||
const choice = choices[i]!;
|
||||
const isSelected = i === view.selectedIndex;
|
||||
const isCurrent = choice.alias === this.opts.currentValue;
|
||||
const pointer = isSelected ? '❯' : ' ';
|
||||
const labelStyle = isSelected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text);
|
||||
|
|
@ -152,19 +175,22 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
|||
|
||||
lines.push('');
|
||||
lines.push(chalk.hex(colors.textMuted)(' Thinking'));
|
||||
const selected = this.selectedChoice();
|
||||
const selected = choices[view.selectedIndex];
|
||||
if (selected !== undefined) {
|
||||
lines.push(this.renderThinkingControl(selected.model));
|
||||
}
|
||||
lines.push('');
|
||||
if (view.page.pageCount > 1) {
|
||||
lines.push(
|
||||
chalk.hex(colors.textMuted)(
|
||||
` Page ${String(view.page.page + 1)}/${String(view.page.pageCount)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
lines.push(chalk.hex(colors.primary)('─'.repeat(width)));
|
||||
return lines.map((line) => truncateToWidth(line, width));
|
||||
}
|
||||
|
||||
private selectedChoice(): ModelChoice | undefined {
|
||||
return this.choices[this.selectedIndex];
|
||||
}
|
||||
|
||||
private renderThinkingControl(model: ModelAlias): string {
|
||||
const { colors } = this.opts;
|
||||
const segment = (label: string, active: boolean): string =>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { OPEN_PLATFORMS } from '@moonshot-ai/kimi-code-oauth';
|
||||
|
||||
import { ChoicePickerComponent, type ChoiceOption } from './choice-picker';
|
||||
|
||||
import type { ColorPalette } from '#/tui/theme/colors';
|
||||
|
||||
const PLATFORM_OPTIONS: readonly ChoiceOption[] = [
|
||||
{ value: 'kimi-code', label: 'Kimi Code' },
|
||||
{ value: 'moonshot-cn', label: 'Moonshot AI Open Platform (moonshot.cn)' },
|
||||
{ value: 'moonshot-ai', label: 'Moonshot AI Open Platform (moonshot.ai)' },
|
||||
...OPEN_PLATFORMS.map((platform) => ({ value: platform.id, label: platform.name })),
|
||||
];
|
||||
|
||||
export interface PlatformSelectorOptions {
|
||||
|
|
|
|||
|
|
@ -29,14 +29,24 @@ import {
|
|||
fetchOpenPlatformModels,
|
||||
filterModelsByPrefix,
|
||||
getOpenPlatformById,
|
||||
isOpenPlatformId,
|
||||
OpenPlatformApiError,
|
||||
type DeviceAuthorization,
|
||||
type ManagedKimiCodeModelInfo,
|
||||
type ManagedKimiConfigShape,
|
||||
type OpenPlatformDefinition,
|
||||
} from '@moonshot-ai/kimi-code-oauth';
|
||||
import { log } from '@moonshot-ai/kimi-code-sdk';
|
||||
import {
|
||||
applyCatalogProvider,
|
||||
catalogBaseUrl,
|
||||
catalogModelToAlias,
|
||||
catalogProviderModels,
|
||||
CatalogFetchError,
|
||||
fetchCatalog,
|
||||
inferWireType,
|
||||
loadBuiltInCatalog,
|
||||
log,
|
||||
} from '@moonshot-ai/kimi-code-sdk';
|
||||
import { BUILT_IN_CATALOG_JSON } from '../built-in-catalog';
|
||||
import type {
|
||||
AgentStatusUpdatedEvent,
|
||||
ApprovalRequest,
|
||||
|
|
@ -46,6 +56,8 @@ import type {
|
|||
BackgroundTaskStartedEvent,
|
||||
BackgroundTaskTerminatedEvent,
|
||||
BackgroundTaskUpdatedEvent,
|
||||
Catalog,
|
||||
CatalogModel,
|
||||
CompactionCancelledEvent,
|
||||
CompactionCompletedEvent,
|
||||
CompactionStartedEvent,
|
||||
|
|
@ -124,6 +136,7 @@ import {
|
|||
type FeedbackInputDialogResult,
|
||||
} from './components/dialogs/feedback-input-dialog';
|
||||
import { HelpPanelComponent } from './components/dialogs/help-panel';
|
||||
import { ChoicePickerComponent, type ChoiceOption } from './components/dialogs/choice-picker';
|
||||
import { ModelSelectorComponent } from './components/dialogs/model-selector';
|
||||
import { PlatformSelectorComponent } from './components/dialogs/platform-selector';
|
||||
import { PermissionSelectorComponent } from './components/dialogs/permission-selector';
|
||||
|
|
@ -208,6 +221,7 @@ import {
|
|||
import { formatBackgroundAgentTranscript } from './utils/background-agent-status';
|
||||
import { formatBackgroundTaskTranscript } from './utils/background-task-status';
|
||||
import { hasDispose, isExpandable, isPlanExpandable } from './utils/component-capabilities';
|
||||
import { resolveConnectCatalogRequest } from './utils/connect-catalog';
|
||||
import {
|
||||
appendStreamingArgsPreview,
|
||||
argsRecord,
|
||||
|
|
@ -1436,6 +1450,9 @@ export class KimiTUI {
|
|||
case 'login':
|
||||
await this.handleLoginCommand();
|
||||
return;
|
||||
case 'connect':
|
||||
await this.handleConnectCommand(args);
|
||||
return;
|
||||
case 'logout':
|
||||
await this.handleLogoutCommand();
|
||||
return;
|
||||
|
|
@ -4435,7 +4452,10 @@ export class KimiTUI {
|
|||
private showModelPicker(selectedValue: string = this.state.appState.model): void {
|
||||
const entries = Object.entries(this.state.appState.availableModels);
|
||||
if (entries.length === 0) {
|
||||
this.showError('No models configured.');
|
||||
this.showNotice(
|
||||
'No models configured',
|
||||
'Run /login to sign in to Kimi, or /connect to add another provider from a model catalog.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.mountEditorReplacement(
|
||||
|
|
@ -4445,6 +4465,7 @@ export class KimiTUI {
|
|||
selectedValue,
|
||||
currentThinking: this.state.appState.thinking,
|
||||
colors: this.state.theme.colors,
|
||||
searchable: true,
|
||||
onSelect: ({ alias, thinking }) => {
|
||||
this.restoreEditor();
|
||||
void this.performModelSwitch(alias, thinking);
|
||||
|
|
@ -5182,6 +5203,122 @@ export class KimiTUI {
|
|||
this.showStatus(`Setup complete: ${platform.name} · ${selection.model.id}`);
|
||||
}
|
||||
|
||||
// Handles the /connect command — fetches a model catalog (default
|
||||
// models.dev), lets the user pick a provider + model, prompts for an API
|
||||
// key, then writes the provider config + model aliases. Model metadata
|
||||
// (context size, capabilities) comes from the catalog, so users do not
|
||||
// hand-write it.
|
||||
private async handleConnectCommand(args: string): Promise<void> {
|
||||
const resolution = resolveConnectCatalogRequest(args);
|
||||
if (resolution.kind === 'error') {
|
||||
this.showError(resolution.message);
|
||||
return;
|
||||
}
|
||||
const { url, preferBuiltIn, allowBuiltInFallback } = resolution.request;
|
||||
|
||||
let catalog: Catalog | undefined;
|
||||
|
||||
// Default path: serve the bundled catalog so /connect works without a
|
||||
// live network and is not gated by models.dev availability. The source
|
||||
// placeholder is undefined in dev builds, so dev falls through to fetch.
|
||||
if (preferBuiltIn) {
|
||||
const builtIn = loadBuiltInCatalog(BUILT_IN_CATALOG_JSON);
|
||||
if (builtIn !== undefined) {
|
||||
this.showStatus('Loaded built-in catalog. Run /connect --refresh for the latest.');
|
||||
catalog = builtIn;
|
||||
}
|
||||
}
|
||||
|
||||
if (catalog === undefined) {
|
||||
const controller = new AbortController();
|
||||
const cancel = (): void => {
|
||||
controller.abort();
|
||||
};
|
||||
this.cancelInFlight = cancel;
|
||||
|
||||
const spinner = this.showLoginProgressSpinner(`Fetching catalog from ${url}`);
|
||||
try {
|
||||
catalog = await fetchCatalog(url, controller.signal);
|
||||
spinner.stop({ ok: true, label: 'Catalog loaded.' });
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
spinner.stop({ ok: false, label: 'Aborted.' });
|
||||
} else {
|
||||
const hint = error instanceof CatalogFetchError ? ` (HTTP ${error.status})` : '';
|
||||
if (!allowBuiltInFallback) {
|
||||
spinner.stop({ ok: false, label: 'Failed to load catalog.' });
|
||||
this.showError(`Failed to fetch catalog${hint}: ${formatErrorMessage(error)}`);
|
||||
} else {
|
||||
const fallback = loadBuiltInCatalog(BUILT_IN_CATALOG_JSON);
|
||||
if (fallback !== undefined) {
|
||||
spinner.stop({ ok: true, label: 'Using built-in catalog (offline mode).' });
|
||||
catalog = fallback;
|
||||
} else {
|
||||
spinner.stop({ ok: false, label: 'Failed to load catalog.' });
|
||||
this.showError(`Failed to fetch catalog${hint}: ${formatErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (this.cancelInFlight === cancel) this.cancelInFlight = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (catalog === undefined) return;
|
||||
|
||||
const providerId = await this.promptCatalogProviderSelection(catalog);
|
||||
if (providerId === undefined) return;
|
||||
const entry = catalog[providerId];
|
||||
if (entry === undefined) return;
|
||||
|
||||
const models = catalogProviderModels(entry);
|
||||
if (models.length === 0) {
|
||||
this.showError(`Provider "${providerId}" has no usable models in this catalog.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = await this.promptModelSelectionForCatalog(providerId, models);
|
||||
if (selection === undefined) return;
|
||||
|
||||
const apiKey = await this.promptApiKey(entry.name ?? providerId);
|
||||
if (apiKey === undefined) return;
|
||||
|
||||
const wire = inferWireType(entry);
|
||||
if (wire === undefined) return;
|
||||
const baseUrl = catalogBaseUrl(entry, wire);
|
||||
|
||||
// Remove stale provider config first: setConfig is a deep-merge patch that
|
||||
// cannot delete keys, and applyCatalogProvider's in-memory cleanup below
|
||||
// does not survive that merge — removeProvider is the only step that
|
||||
// actually drops old model aliases from disk.
|
||||
const existingConfig = await this.harness.getConfig();
|
||||
if (existingConfig.providers[providerId] !== undefined) {
|
||||
await this.harness.removeProvider(providerId);
|
||||
}
|
||||
|
||||
const config = await this.harness.getConfig();
|
||||
applyCatalogProvider(config, {
|
||||
providerId,
|
||||
wire,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
models,
|
||||
selectedModelId: selection.model.id,
|
||||
thinking: selection.thinking,
|
||||
});
|
||||
|
||||
await this.harness.setConfig({
|
||||
providers: config.providers,
|
||||
models: config.models,
|
||||
defaultModel: config.defaultModel,
|
||||
defaultThinking: config.defaultThinking,
|
||||
});
|
||||
|
||||
await this.refreshConfigAfterLogin();
|
||||
this.track('connect', { provider: providerId, model: selection.model.id });
|
||||
this.showStatus(`Connected: ${entry.name ?? providerId} · ${selection.model.id}`);
|
||||
}
|
||||
|
||||
// Handles the /feedback command — opens an inline input dialog and POSTs
|
||||
// the result to the managed Kimi Code platform. Falls back to the GitHub
|
||||
// Issues page when the user is not signed in or the request fails.
|
||||
|
|
@ -5250,7 +5387,11 @@ export class KimiTUI {
|
|||
return;
|
||||
}
|
||||
|
||||
if (isOpenPlatformId(currentProvider)) {
|
||||
// Any other provider written into config — OpenPlatform OAuth targets and
|
||||
// /connect-configured catalog providers both go through removeProvider,
|
||||
// which drops the provider entry and its model aliases together.
|
||||
const existingConfig = await this.harness.getConfig();
|
||||
if (existingConfig.providers[currentProvider] !== undefined) {
|
||||
await this.harness.removeProvider(currentProvider);
|
||||
await this.refreshConfigAfterLogout();
|
||||
await this.clearActiveSessionAfterLogout();
|
||||
|
|
@ -5283,6 +5424,42 @@ export class KimiTUI {
|
|||
});
|
||||
}
|
||||
|
||||
private promptCatalogProviderSelection(catalog: Catalog): Promise<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const options: ChoiceOption[] = Object.entries(catalog)
|
||||
.filter(([, entry]) => inferWireType(entry) !== undefined)
|
||||
.map(([id, entry]) => ({
|
||||
value: id,
|
||||
label: entry.name ?? id,
|
||||
description:
|
||||
typeof entry.api === 'string' && entry.api.length > 0 ? entry.api : undefined,
|
||||
}))
|
||||
.toSorted((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
if (options.length === 0) {
|
||||
this.showError('Catalog has no providers with supported wire types.');
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const picker = new ChoicePickerComponent({
|
||||
title: 'Select a provider',
|
||||
options,
|
||||
colors: this.state.theme.colors,
|
||||
searchable: true,
|
||||
onSelect: (value) => {
|
||||
this.restoreEditor();
|
||||
resolve(value);
|
||||
},
|
||||
onCancel: () => {
|
||||
this.restoreEditor();
|
||||
resolve(undefined);
|
||||
},
|
||||
});
|
||||
this.mountEditorReplacement(picker);
|
||||
});
|
||||
}
|
||||
|
||||
private promptApiKey(platformName: string): Promise<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const dialog = new ApiKeyInputDialogComponent(
|
||||
|
|
@ -5297,39 +5474,56 @@ export class KimiTUI {
|
|||
});
|
||||
}
|
||||
|
||||
private promptModelSelectionForOpenPlatform(
|
||||
private async promptModelSelectionForOpenPlatform(
|
||||
models: ManagedKimiCodeModelInfo[],
|
||||
platform: OpenPlatformDefinition,
|
||||
): Promise<{ model: ManagedKimiCodeModelInfo; thinking: boolean } | undefined> {
|
||||
const modelDict: Record<string, ModelAlias> = {};
|
||||
for (const m of models) {
|
||||
modelDict[`${platform.id}/${m.id}`] = {
|
||||
provider: platform.id,
|
||||
model: m.id,
|
||||
maxContextSize: m.contextLength,
|
||||
capabilities: capabilitiesForModel(m),
|
||||
displayName: m.displayName,
|
||||
};
|
||||
}
|
||||
const selection = await this.runModelSelector(modelDict);
|
||||
if (selection === undefined) return undefined;
|
||||
const model = models.find((m) => `${platform.id}/${m.id}` === selection.alias);
|
||||
return model ? { model, thinking: selection.thinking } : undefined;
|
||||
}
|
||||
|
||||
private async promptModelSelectionForCatalog(
|
||||
providerId: string,
|
||||
models: CatalogModel[],
|
||||
): Promise<{ model: CatalogModel; thinking: boolean } | undefined> {
|
||||
const modelDict: Record<string, ModelAlias> = {};
|
||||
for (const m of models) {
|
||||
modelDict[`${providerId}/${m.id}`] = catalogModelToAlias(providerId, m);
|
||||
}
|
||||
const selection = await this.runModelSelector(modelDict);
|
||||
if (selection === undefined) return undefined;
|
||||
const model = models.find((m) => `${providerId}/${m.id}` === selection.alias);
|
||||
return model ? { model, thinking: selection.thinking } : undefined;
|
||||
}
|
||||
|
||||
private runModelSelector(
|
||||
modelDict: Record<string, ModelAlias>,
|
||||
): Promise<{ alias: string; thinking: boolean } | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const modelDict: Record<string, ModelAlias> = {};
|
||||
for (const m of models) {
|
||||
const alias = `${platform.id}/${m.id}`;
|
||||
modelDict[alias] = {
|
||||
provider: platform.id,
|
||||
model: m.id,
|
||||
maxContextSize: m.contextLength,
|
||||
capabilities: capabilitiesForModel(m),
|
||||
displayName: m.displayName,
|
||||
};
|
||||
}
|
||||
|
||||
const firstAlias = Object.keys(modelDict)[0] ?? '';
|
||||
const firstModel = modelDict[firstAlias];
|
||||
const initialThinking = (() => {
|
||||
const caps = firstModel?.capabilities ?? [];
|
||||
return caps.includes('always_thinking') || caps.includes('thinking');
|
||||
})();
|
||||
|
||||
const caps = modelDict[firstAlias]?.capabilities ?? [];
|
||||
const initialThinking = caps.includes('always_thinking') || caps.includes('thinking');
|
||||
const selector = new ModelSelectorComponent({
|
||||
models: modelDict,
|
||||
currentValue: firstAlias,
|
||||
currentThinking: initialThinking,
|
||||
colors: this.state.theme.colors,
|
||||
searchable: true,
|
||||
onSelect: ({ alias, thinking }) => {
|
||||
this.restoreEditor();
|
||||
const model = models.find((m) => `${platform.id}/${m.id}` === alias);
|
||||
resolve(model ? { model, thinking } : undefined);
|
||||
resolve({ alias, thinking });
|
||||
},
|
||||
onCancel: () => {
|
||||
this.restoreEditor();
|
||||
|
|
|
|||
52
apps/kimi-code/src/tui/utils/connect-catalog.ts
Normal file
52
apps/kimi-code/src/tui/utils/connect-catalog.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { DEFAULT_CATALOG_URL } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
const CATALOG_URL_FLAG_RE = /--url(?:=|\s+)(https?:\/\/\S+)/;
|
||||
const URL_FLAG_PRESENT_RE = /(?:^|\s)--url(?=\s|=|$)/;
|
||||
const REFRESH_FLAG_RE = /(?:^|\s)--refresh(?=\s|$)/;
|
||||
const BARE_HTTP_URL_RE = /^https?:\/\/\S+$/;
|
||||
|
||||
export interface ConnectCatalogRequest {
|
||||
readonly url: string;
|
||||
readonly preferBuiltIn: boolean;
|
||||
readonly allowBuiltInFallback: boolean;
|
||||
}
|
||||
|
||||
export type ConnectCatalogResolution =
|
||||
| { readonly kind: 'ok'; readonly request: ConnectCatalogRequest }
|
||||
| { readonly kind: 'error'; readonly message: string };
|
||||
|
||||
export function resolveConnectCatalogRequest(args: string): ConnectCatalogResolution {
|
||||
const trimmed = args.trim();
|
||||
const urlMatch = CATALOG_URL_FLAG_RE.exec(trimmed);
|
||||
const bareUrl = BARE_HTTP_URL_RE.test(trimmed) ? trimmed : undefined;
|
||||
const explicitUrl = urlMatch?.[1] ?? bareUrl;
|
||||
|
||||
if (explicitUrl !== undefined) {
|
||||
return {
|
||||
kind: 'ok',
|
||||
request: {
|
||||
url: explicitUrl,
|
||||
preferBuiltIn: false,
|
||||
allowBuiltInFallback: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (URL_FLAG_PRESENT_RE.test(trimmed)) {
|
||||
return {
|
||||
kind: 'error',
|
||||
message:
|
||||
'--url requires an http(s) URL value, e.g. /connect --url=https://example.com/catalog.json',
|
||||
};
|
||||
}
|
||||
|
||||
const refreshRequested = REFRESH_FLAG_RE.test(trimmed);
|
||||
return {
|
||||
kind: 'ok',
|
||||
request: {
|
||||
url: DEFAULT_CATALOG_URL,
|
||||
preferBuiltIn: !refreshRequested,
|
||||
allowBuiltInFallback: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
28
apps/kimi-code/src/tui/utils/paging.ts
Normal file
28
apps/kimi-code/src/tui/utils/paging.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* Pure paging math shared by list pickers (ChoicePicker, ModelSelector).
|
||||
*
|
||||
* The component owns a single `selectedIndex` into its (already filtered)
|
||||
* item list; the page is derived from it, so ↑↓ moves the cursor smoothly
|
||||
* across page boundaries while the view still shows an explicit page number.
|
||||
*/
|
||||
|
||||
export interface PageView {
|
||||
/** Zero-based index of the page containing `selectedIndex`. */
|
||||
readonly page: number;
|
||||
/** Total number of pages; always at least 1, even for an empty list. */
|
||||
readonly pageCount: number;
|
||||
/** Inclusive slice start of the current page. */
|
||||
readonly start: number;
|
||||
/** Exclusive slice end of the current page (clamped to `total`). */
|
||||
readonly end: number;
|
||||
}
|
||||
|
||||
export function pageView(total: number, selectedIndex: number, pageSize: number): PageView {
|
||||
const size = Math.max(1, Math.floor(pageSize));
|
||||
const pageCount = Math.max(1, Math.ceil(total / size));
|
||||
const safeIndex = total <= 0 ? 0 : Math.min(Math.max(0, selectedIndex), total - 1);
|
||||
const page = Math.min(Math.floor(safeIndex / size), pageCount - 1);
|
||||
const start = page * size;
|
||||
const end = Math.min(start + size, total);
|
||||
return { page, pageCount, start, end };
|
||||
}
|
||||
|
|
@ -25,3 +25,14 @@ import { decodeKittyPrintable } from '@earendil-works/pi-tui';
|
|||
export function printableChar(data: string): string {
|
||||
return decodeKittyPrintable(data) ?? data;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a decoded key is a single printable character safe to append to a
|
||||
* text query (e.g. a search box). Rejects C0 control chars, DEL, and any
|
||||
* multi-codepoint escape sequence. Space is accepted.
|
||||
*/
|
||||
export function isPrintableChar(ch: string): boolean {
|
||||
if (ch.length !== 1) return false;
|
||||
const code = ch.codePointAt(0)!;
|
||||
return code >= 0x20 && code !== 0x7f;
|
||||
}
|
||||
|
|
|
|||
140
apps/kimi-code/src/tui/utils/searchable-list.ts
Normal file
140
apps/kimi-code/src/tui/utils/searchable-list.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* Cursor + fuzzy-search + paging state machine shared by list pickers
|
||||
* (ChoicePicker, ModelSelector). Pure logic, no rendering.
|
||||
*
|
||||
* The component owns presentation and the keys that carry component-specific
|
||||
* meaning — Enter (submit), Esc (cancel), and ←/→ (paging in one picker, a
|
||||
* thinking toggle in another). This unit owns the keys that behave identically
|
||||
* everywhere: ↑/↓, PgUp/PgDn, and search editing.
|
||||
*/
|
||||
|
||||
import { fuzzyFilter, Key, matchesKey } from '@earendil-works/pi-tui';
|
||||
|
||||
import { pageView, type PageView } from './paging';
|
||||
import { isPrintableChar, printableChar } from './printable-key';
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 8;
|
||||
|
||||
export interface SearchableListOptions<T> {
|
||||
readonly items: readonly T[];
|
||||
/** Text a list item is fuzzy-matched against. */
|
||||
readonly toSearchText: (item: T) => string;
|
||||
/** Items per page; defaults to 8. */
|
||||
readonly pageSize?: number;
|
||||
/** Initial cursor position (clamped to >= 0). */
|
||||
readonly initialIndex?: number;
|
||||
/** When false, typed characters are ignored. Defaults to false. */
|
||||
readonly searchable?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchableListView<T> {
|
||||
/** Items after the active query filter. */
|
||||
readonly items: readonly T[];
|
||||
/** Page math for the current cursor over {@link items}. */
|
||||
readonly page: PageView;
|
||||
/** Cursor clamped into the current {@link items} range. */
|
||||
readonly selectedIndex: number;
|
||||
readonly query: string;
|
||||
}
|
||||
|
||||
export class SearchableList<T> {
|
||||
private readonly items: readonly T[];
|
||||
private readonly toSearchText: (item: T) => string;
|
||||
private readonly pageSize: number;
|
||||
private readonly searchable: boolean;
|
||||
private query = '';
|
||||
private cursor: number;
|
||||
|
||||
constructor(opts: SearchableListOptions<T>) {
|
||||
this.items = opts.items;
|
||||
this.toSearchText = opts.toSearchText;
|
||||
this.pageSize = opts.pageSize ?? DEFAULT_PAGE_SIZE;
|
||||
this.searchable = opts.searchable ?? false;
|
||||
this.cursor = Math.max(opts.initialIndex ?? 0, 0);
|
||||
}
|
||||
|
||||
filtered(): readonly T[] {
|
||||
if (this.query.length === 0) return this.items;
|
||||
return fuzzyFilter([...this.items], this.query, this.toSearchText);
|
||||
}
|
||||
|
||||
/** The item under the cursor, clamped into the filtered range. */
|
||||
selected(): T | undefined {
|
||||
const items = this.filtered();
|
||||
if (items.length === 0) return undefined;
|
||||
return items[Math.min(this.cursor, items.length - 1)];
|
||||
}
|
||||
|
||||
view(): SearchableListView<T> {
|
||||
const items = this.filtered();
|
||||
return {
|
||||
items,
|
||||
page: pageView(items.length, this.cursor, this.pageSize),
|
||||
selectedIndex: Math.min(this.cursor, Math.max(0, items.length - 1)),
|
||||
query: this.query,
|
||||
};
|
||||
}
|
||||
|
||||
moveUp(): void {
|
||||
this.cursor = Math.max(0, this.cursor - 1);
|
||||
}
|
||||
|
||||
moveDown(): void {
|
||||
this.cursor = Math.min(Math.max(0, this.filtered().length - 1), this.cursor + 1);
|
||||
}
|
||||
|
||||
pageUp(): void {
|
||||
this.cursor = Math.max(0, this.cursor - this.pageSize);
|
||||
}
|
||||
|
||||
pageDown(): void {
|
||||
this.cursor = Math.min(Math.max(0, this.filtered().length - 1), this.cursor + this.pageSize);
|
||||
}
|
||||
|
||||
/** Clears the active query and resets the cursor. Returns whether a query was cleared. */
|
||||
clearQuery(): boolean {
|
||||
if (this.query.length === 0) return false;
|
||||
this.query = '';
|
||||
this.cursor = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the keys every picker shares: ↑/↓, PgUp/PgDn, and — when searchable —
|
||||
* Backspace and printable characters. Returns true when the key was consumed.
|
||||
* Enter, Esc, and ←/→ are intentionally left to the component.
|
||||
*/
|
||||
handleKey(data: string): boolean {
|
||||
if (matchesKey(data, Key.up)) {
|
||||
this.moveUp();
|
||||
return true;
|
||||
}
|
||||
if (matchesKey(data, Key.down)) {
|
||||
this.moveDown();
|
||||
return true;
|
||||
}
|
||||
if (matchesKey(data, Key.pageUp)) {
|
||||
this.pageUp();
|
||||
return true;
|
||||
}
|
||||
if (matchesKey(data, Key.pageDown)) {
|
||||
this.pageDown();
|
||||
return true;
|
||||
}
|
||||
if (!this.searchable) return false;
|
||||
if (matchesKey(data, Key.backspace)) {
|
||||
if (this.query.length > 0) {
|
||||
this.query = this.query.slice(0, -1);
|
||||
this.cursor = 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const ch = printableChar(data);
|
||||
if (isPrintableChar(ch)) {
|
||||
this.query += ch;
|
||||
this.cursor = 0;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChoicePickerComponent } from '#/tui/components/dialogs/choice-picker';
|
||||
|
|
@ -200,3 +201,180 @@ describe('ChoicePickerComponent', () => {
|
|||
expect(onSelect).toHaveBeenCalledWith({ alias: 'thinking', thinking: true });
|
||||
});
|
||||
});
|
||||
|
||||
const ESC = String.fromCodePoint(27);
|
||||
const BACKSPACE = String.fromCodePoint(127);
|
||||
const PAGE_UP = `${ESC}[5~`;
|
||||
const PAGE_DOWN = `${ESC}[6~`;
|
||||
const LEFT = `${ESC}[D`;
|
||||
const RIGHT = `${ESC}[C`;
|
||||
const ENTER = String.fromCodePoint(13);
|
||||
|
||||
function rendered(component: { render: (w: number) => string[] }, width = 80): string {
|
||||
return component.render(width).map(strip).join('\n');
|
||||
}
|
||||
|
||||
describe('ChoicePickerComponent search and pagination', () => {
|
||||
function makePicker(over: { options?: { value: string; label: string }[]; searchable?: boolean }) {
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
const picker = new ChoicePickerComponent({
|
||||
title: 'Select a provider',
|
||||
options:
|
||||
over.options ??
|
||||
['openai', 'openrouter', 'anthropic', 'google', 'mistral', 'cohere'].map((label) => ({
|
||||
value: label,
|
||||
label,
|
||||
})),
|
||||
colors: darkColors,
|
||||
searchable: over.searchable ?? true,
|
||||
onSelect,
|
||||
onCancel,
|
||||
});
|
||||
return { picker, onSelect, onCancel };
|
||||
}
|
||||
|
||||
function type(picker: ChoicePickerComponent, query: string): void {
|
||||
for (const ch of query) picker.handleInput(ch);
|
||||
}
|
||||
|
||||
it('filters the list as the user types and echoes the query', () => {
|
||||
const { picker } = makePicker({});
|
||||
type(picker, 'open');
|
||||
const out = rendered(picker);
|
||||
expect(out).toContain('Search: open');
|
||||
expect(out).toContain('openai');
|
||||
expect(out).toContain('openrouter');
|
||||
expect(out).not.toContain('anthropic');
|
||||
expect(out).not.toContain('google');
|
||||
});
|
||||
|
||||
it('trims the query on Backspace and clears it on Esc before cancelling', () => {
|
||||
const { picker, onCancel } = makePicker({});
|
||||
type(picker, 'open');
|
||||
expect(rendered(picker)).toContain('Search: open');
|
||||
|
||||
picker.handleInput(BACKSPACE);
|
||||
expect(rendered(picker)).toContain('Search: ope');
|
||||
|
||||
picker.handleInput(ESC); // non-empty query → clear, do not cancel
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
expect(rendered(picker)).not.toContain('Search:');
|
||||
expect(rendered(picker)).toContain('anthropic'); // full list restored
|
||||
|
||||
picker.handleInput(ESC); // empty query → cancel
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Enter selects the highlighted item from the filtered list', () => {
|
||||
const { picker, onSelect } = makePicker({});
|
||||
type(picker, 'router'); // only openrouter matches
|
||||
picker.handleInput(ENTER);
|
||||
expect(onSelect).toHaveBeenCalledWith('openrouter');
|
||||
});
|
||||
|
||||
it('shows "No matches" and selects nothing when the query matches nothing', () => {
|
||||
const { picker, onSelect } = makePicker({});
|
||||
type(picker, 'zzzz');
|
||||
expect(rendered(picker)).toContain('No matches');
|
||||
picker.handleInput(ENTER);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('splits a long list into pages and pages with PageDown and Right', () => {
|
||||
const options = Array.from({ length: 20 }, (_, i) => {
|
||||
const label = `item${String(i).padStart(2, '0')}`;
|
||||
return { value: label, label };
|
||||
});
|
||||
const { picker } = makePicker({ options, searchable: false });
|
||||
|
||||
expect(rendered(picker)).toContain('Page 1/3');
|
||||
expect(rendered(picker)).toContain('item00');
|
||||
expect(rendered(picker)).not.toContain('item08');
|
||||
|
||||
picker.handleInput(PAGE_DOWN);
|
||||
expect(rendered(picker)).toContain('Page 2/3');
|
||||
expect(rendered(picker)).toContain('item08');
|
||||
expect(rendered(picker)).not.toContain('item00');
|
||||
|
||||
picker.handleInput(RIGHT);
|
||||
expect(rendered(picker)).toContain('Page 3/3');
|
||||
expect(rendered(picker)).toContain('item19');
|
||||
});
|
||||
|
||||
it('omits the page footer for a short list', () => {
|
||||
const { picker } = makePicker({ searchable: false });
|
||||
expect(rendered(picker)).not.toContain('Page ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ModelSelectorComponent search and pagination', () => {
|
||||
function buildModels(count: number): Record<string, ModelAlias> {
|
||||
const models: Record<string, ModelAlias> = {};
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = `model${String(i).padStart(2, '0')}`;
|
||||
models[`prov/${id}`] = {
|
||||
provider: 'prov',
|
||||
model: id,
|
||||
maxContextSize: 1000,
|
||||
capabilities: ['thinking'],
|
||||
};
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
function makeSelector(models: Record<string, ModelAlias>, currentThinking = true) {
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
const firstAlias = Object.keys(models)[0] ?? '';
|
||||
const selector = new ModelSelectorComponent({
|
||||
models,
|
||||
currentValue: firstAlias,
|
||||
currentThinking,
|
||||
colors: darkColors,
|
||||
searchable: true,
|
||||
onSelect,
|
||||
onCancel,
|
||||
});
|
||||
return { selector, onSelect, onCancel };
|
||||
}
|
||||
|
||||
it('filters models as the user types', () => {
|
||||
const { selector } = makeSelector({
|
||||
'p/alpha': { provider: 'p', model: 'alpha', maxContextSize: 1000 },
|
||||
'p/beta': { provider: 'p', model: 'beta', maxContextSize: 1000 },
|
||||
'p/gamma': { provider: 'p', model: 'gamma', maxContextSize: 1000 },
|
||||
});
|
||||
for (const ch of 'beta') selector.handleInput(ch);
|
||||
const out = rendered(selector);
|
||||
expect(out).toContain('Search: beta');
|
||||
expect(out).toContain('beta (p)');
|
||||
expect(out).not.toContain('alpha (p)');
|
||||
expect(out).not.toContain('gamma (p)');
|
||||
});
|
||||
|
||||
it('pages with PageDown/PageUp while Left/Right still toggle thinking', () => {
|
||||
const { selector } = makeSelector(buildModels(20));
|
||||
|
||||
expect(rendered(selector)).toContain('Page 1/3');
|
||||
expect(rendered(selector)).toContain('model00 (prov)');
|
||||
expect(rendered(selector)).not.toContain('model08 (prov)');
|
||||
|
||||
selector.handleInput(PAGE_DOWN);
|
||||
expect(rendered(selector)).toContain('Page 2/3');
|
||||
expect(rendered(selector)).toContain('model08 (prov)');
|
||||
|
||||
// Right toggles thinking off and must NOT change the page.
|
||||
selector.handleInput(RIGHT);
|
||||
expect(rendered(selector)).toContain('Page 2/3');
|
||||
expect(rendered(selector)).toContain('[ Off ]');
|
||||
|
||||
// Left toggles thinking back on, page still unchanged.
|
||||
selector.handleInput(LEFT);
|
||||
expect(rendered(selector)).toContain('Page 2/3');
|
||||
expect(rendered(selector)).toContain('[ On ]');
|
||||
|
||||
selector.handleInput(PAGE_UP);
|
||||
expect(rendered(selector)).toContain('Page 1/3');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -36,6 +36,21 @@ interface FeedbackDriver extends MessageDriver {
|
|||
promptFeedbackInput(): Promise<string | undefined>;
|
||||
}
|
||||
|
||||
interface ModelSelectorDriver extends MessageDriver {
|
||||
runModelSelector(
|
||||
models: Record<
|
||||
string,
|
||||
{
|
||||
provider: string;
|
||||
model: string;
|
||||
maxContextSize: number;
|
||||
displayName?: string;
|
||||
capabilities?: string[];
|
||||
}
|
||||
>,
|
||||
): Promise<{ alias: string; thinking: boolean } | undefined>;
|
||||
}
|
||||
|
||||
function makeStartupInput(): KimiTUIStartupInput {
|
||||
return {
|
||||
cliOptions: {
|
||||
|
|
@ -1178,6 +1193,12 @@ describe('KimiTUI message flow', () => {
|
|||
const pickerOutput = stripSgr((picker as ModelSelectorComponent).render(120).join('\n'));
|
||||
expect(pickerOutput).toContain('Kimi K2 (Kimi Code) ← current');
|
||||
expect(pickerOutput).toContain('❯ Kimi Turbo (Kimi Code)');
|
||||
(picker as ModelSelectorComponent).handleInput('t');
|
||||
(picker as ModelSelectorComponent).handleInput('u');
|
||||
const filteredOutput = stripSgr((picker as ModelSelectorComponent).render(120).join('\n'));
|
||||
expect(filteredOutput).toContain('Search: tu');
|
||||
expect(filteredOutput).toContain('Kimi Turbo (Kimi Code)');
|
||||
expect(filteredOutput).not.toContain('Kimi K2 (Kimi Code)');
|
||||
(picker as ModelSelectorComponent).handleInput('\u001B[D');
|
||||
(picker as ModelSelectorComponent).handleInput('\r');
|
||||
|
||||
|
|
@ -1229,6 +1250,41 @@ describe('KimiTUI message flow', () => {
|
|||
expect(session.setThinking).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('enables search in the shared model selector helper', async () => {
|
||||
const { driver } = await makeDriver();
|
||||
const selectorDriver = driver as unknown as ModelSelectorDriver;
|
||||
const selection = selectorDriver.runModelSelector({
|
||||
alpha: {
|
||||
provider: 'managed:kimi-code',
|
||||
model: 'kimi-alpha',
|
||||
maxContextSize: 100,
|
||||
displayName: 'Kimi Alpha',
|
||||
capabilities: ['thinking'],
|
||||
},
|
||||
turbo: {
|
||||
provider: 'managed:kimi-code',
|
||||
model: 'kimi-turbo',
|
||||
maxContextSize: 100,
|
||||
displayName: 'Kimi Turbo',
|
||||
capabilities: ['thinking'],
|
||||
},
|
||||
});
|
||||
|
||||
const picker = driver.state.editorContainer.children[0];
|
||||
expect(picker).toBeInstanceOf(ModelSelectorComponent);
|
||||
(picker as ModelSelectorComponent).handleInput('t');
|
||||
(picker as ModelSelectorComponent).handleInput('u');
|
||||
|
||||
const output = stripSgr((picker as ModelSelectorComponent).render(120).join('\n'));
|
||||
expect(output).toContain('Search: tu');
|
||||
expect(output).toContain('Kimi Turbo (Kimi Code)');
|
||||
expect(output).not.toContain('Kimi Alpha (Kimi Code)');
|
||||
|
||||
(picker as ModelSelectorComponent).handleInput('\u001B');
|
||||
(picker as ModelSelectorComponent).handleInput('\u001B');
|
||||
await expect(selection).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('deletes Kitty inline images when /new clears the transcript', async () => {
|
||||
setCapabilities({ images: 'kitty', trueColor: true, hyperlinks: true });
|
||||
const { driver, harness } = await makeDriver(makeSession({ id: 'ses-1' }));
|
||||
|
|
|
|||
158
apps/kimi-code/test/tui/utils/connect-catalog.test.ts
Normal file
158
apps/kimi-code/test/tui/utils/connect-catalog.test.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { DEFAULT_CATALOG_URL, loadBuiltInCatalog } from '@moonshot-ai/kimi-code-sdk';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { BUILT_IN_CATALOG_JSON } from '#/built-in-catalog';
|
||||
import { resolveConnectCatalogRequest } from '#/tui/utils/connect-catalog';
|
||||
|
||||
import { builtInCatalogDefine } from '../../../scripts/built-in-catalog.mjs';
|
||||
|
||||
describe('resolveConnectCatalogRequest', () => {
|
||||
it('prefers the built-in catalog by default and keeps online fetch as fallback', () => {
|
||||
expect(resolveConnectCatalogRequest('')).toEqual({
|
||||
kind: 'ok',
|
||||
request: {
|
||||
url: DEFAULT_CATALOG_URL,
|
||||
preferBuiltIn: true,
|
||||
allowBuiltInFallback: true,
|
||||
},
|
||||
});
|
||||
expect(resolveConnectCatalogRequest('ignored text')).toEqual({
|
||||
kind: 'ok',
|
||||
request: {
|
||||
url: DEFAULT_CATALOG_URL,
|
||||
preferBuiltIn: true,
|
||||
allowBuiltInFallback: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('forces an online fetch when --refresh is requested', () => {
|
||||
expect(resolveConnectCatalogRequest('--refresh')).toEqual({
|
||||
kind: 'ok',
|
||||
request: {
|
||||
url: DEFAULT_CATALOG_URL,
|
||||
preferBuiltIn: false,
|
||||
allowBuiltInFallback: true,
|
||||
},
|
||||
});
|
||||
expect(resolveConnectCatalogRequest(' --refresh ')).toEqual({
|
||||
kind: 'ok',
|
||||
request: {
|
||||
url: DEFAULT_CATALOG_URL,
|
||||
preferBuiltIn: false,
|
||||
allowBuiltInFallback: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('treats explicit catalog URLs as authoritative and ignores --refresh on them', () => {
|
||||
expect(resolveConnectCatalogRequest('--url=https://internal.example/catalog.json')).toEqual({
|
||||
kind: 'ok',
|
||||
request: {
|
||||
url: 'https://internal.example/catalog.json',
|
||||
preferBuiltIn: false,
|
||||
allowBuiltInFallback: false,
|
||||
},
|
||||
});
|
||||
expect(resolveConnectCatalogRequest('--url https://internal.example/catalog.json')).toEqual({
|
||||
kind: 'ok',
|
||||
request: {
|
||||
url: 'https://internal.example/catalog.json',
|
||||
preferBuiltIn: false,
|
||||
allowBuiltInFallback: false,
|
||||
},
|
||||
});
|
||||
expect(resolveConnectCatalogRequest('https://internal.example/catalog.json')).toEqual({
|
||||
kind: 'ok',
|
||||
request: {
|
||||
url: 'https://internal.example/catalog.json',
|
||||
preferBuiltIn: false,
|
||||
allowBuiltInFallback: false,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
resolveConnectCatalogRequest('--refresh --url=https://internal.example/catalog.json'),
|
||||
).toEqual({
|
||||
kind: 'ok',
|
||||
request: {
|
||||
url: 'https://internal.example/catalog.json',
|
||||
preferBuiltIn: false,
|
||||
allowBuiltInFallback: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects --url when no value or a non-URL value is provided', () => {
|
||||
const expectedMessage =
|
||||
'--url requires an http(s) URL value, e.g. /connect --url=https://example.com/catalog.json';
|
||||
expect(resolveConnectCatalogRequest('--url')).toEqual({
|
||||
kind: 'error',
|
||||
message: expectedMessage,
|
||||
});
|
||||
expect(resolveConnectCatalogRequest('--url=')).toEqual({
|
||||
kind: 'error',
|
||||
message: expectedMessage,
|
||||
});
|
||||
expect(resolveConnectCatalogRequest(' --url ')).toEqual({
|
||||
kind: 'error',
|
||||
message: expectedMessage,
|
||||
});
|
||||
expect(resolveConnectCatalogRequest('--refresh --url')).toEqual({
|
||||
kind: 'error',
|
||||
message: expectedMessage,
|
||||
});
|
||||
// Flag-like tokens after --url must not be swallowed as the URL value.
|
||||
expect(resolveConnectCatalogRequest('--url --refresh')).toEqual({
|
||||
kind: 'error',
|
||||
message: expectedMessage,
|
||||
});
|
||||
// Plain non-URL tokens must also be rejected, not silently used.
|
||||
expect(resolveConnectCatalogRequest('--url not-a-url')).toEqual({
|
||||
kind: 'error',
|
||||
message: expectedMessage,
|
||||
});
|
||||
expect(resolveConnectCatalogRequest('--url=ftp://example.com/x')).toEqual({
|
||||
kind: 'error',
|
||||
message: expectedMessage,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('built-in connect catalog injection', () => {
|
||||
it('keeps the source placeholder empty so generated catalog data is not committed', () => {
|
||||
expect(BUILT_IN_CATALOG_JSON).toBeUndefined();
|
||||
expect(loadBuiltInCatalog(BUILT_IN_CATALOG_JSON)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('embeds a generated catalog file through the tsdown define value', async () => {
|
||||
const catalog = {
|
||||
openai: {
|
||||
id: 'openai',
|
||||
npm: '@ai-sdk/openai',
|
||||
models: {
|
||||
'gpt-test': {
|
||||
id: 'gpt-test',
|
||||
limit: { context: 1000, output: 100 },
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const dir = await mkdtemp(join(tmpdir(), 'kimi-built-in-catalog-'));
|
||||
try {
|
||||
const file = join(dir, 'catalog.json');
|
||||
const text = JSON.stringify(catalog);
|
||||
await writeFile(file, text, 'utf-8');
|
||||
|
||||
const defineValue = builtInCatalogDefine({ KIMI_CODE_BUILT_IN_CATALOG_FILE: file });
|
||||
expect(JSON.parse(defineValue)).toBe(text);
|
||||
expect(loadBuiltInCatalog(JSON.parse(defineValue))).toEqual(catalog);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
39
apps/kimi-code/test/tui/utils/paging.test.ts
Normal file
39
apps/kimi-code/test/tui/utils/paging.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { pageView } from '@/tui/utils/paging';
|
||||
|
||||
describe('pageView', () => {
|
||||
it('keeps the selected index on the first page', () => {
|
||||
expect(pageView(60, 3, 8)).toEqual({ page: 0, pageCount: 8, start: 0, end: 8 });
|
||||
});
|
||||
|
||||
it('derives the page containing the selected index', () => {
|
||||
// index 12 with pageSize 8 lives on page 1 (items 8..15).
|
||||
expect(pageView(60, 12, 8)).toEqual({ page: 1, pageCount: 8, start: 8, end: 16 });
|
||||
});
|
||||
|
||||
it('clamps the final page slice to the total', () => {
|
||||
// 60 items, pageSize 8 → last page is page 7 (items 56..59).
|
||||
expect(pageView(60, 59, 8)).toEqual({ page: 7, pageCount: 8, start: 56, end: 60 });
|
||||
});
|
||||
|
||||
it('clamps a selectedIndex past the end onto the last page', () => {
|
||||
expect(pageView(10, 999, 4)).toEqual({ page: 2, pageCount: 3, start: 8, end: 10 });
|
||||
});
|
||||
|
||||
it('clamps a negative selectedIndex to the first page', () => {
|
||||
expect(pageView(10, -5, 4)).toEqual({ page: 0, pageCount: 3, start: 0, end: 4 });
|
||||
});
|
||||
|
||||
it('returns a single page when pageSize exceeds the total', () => {
|
||||
expect(pageView(5, 4, 8)).toEqual({ page: 0, pageCount: 1, start: 0, end: 5 });
|
||||
});
|
||||
|
||||
it('returns a single empty page for an empty list', () => {
|
||||
expect(pageView(0, 0, 8)).toEqual({ page: 0, pageCount: 1, start: 0, end: 0 });
|
||||
});
|
||||
|
||||
it('treats a non-positive pageSize as size 1', () => {
|
||||
expect(pageView(3, 2, 0)).toEqual({ page: 2, pageCount: 3, start: 2, end: 3 });
|
||||
});
|
||||
});
|
||||
100
apps/kimi-code/test/tui/utils/searchable-list.test.ts
Normal file
100
apps/kimi-code/test/tui/utils/searchable-list.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { SearchableList, type SearchableListOptions } from '#/tui/utils/searchable-list';
|
||||
|
||||
const ESC = String.fromCodePoint(27);
|
||||
const UP = `${ESC}[A`;
|
||||
const DOWN = `${ESC}[B`;
|
||||
const PAGE_UP = `${ESC}[5~`;
|
||||
const PAGE_DOWN = `${ESC}[6~`;
|
||||
const BACKSPACE = String.fromCodePoint(127);
|
||||
|
||||
const ITEMS = Array.from({ length: 10 }, (_, i) => `item${String(i).padStart(2, '0')}`);
|
||||
|
||||
function make(over: Partial<SearchableListOptions<string>> = {}): SearchableList<string> {
|
||||
return new SearchableList<string>({
|
||||
items: ITEMS,
|
||||
toSearchText: (s) => s,
|
||||
pageSize: 4,
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
describe('SearchableList', () => {
|
||||
it('derives page math from the cursor and pages by pageSize', () => {
|
||||
const list = make({ initialIndex: 0 });
|
||||
let v = list.view();
|
||||
expect(v.page.pageCount).toBe(3); // ceil(10 / 4)
|
||||
expect([v.page.start, v.page.end]).toEqual([0, 4]);
|
||||
expect(v.selectedIndex).toBe(0);
|
||||
|
||||
list.pageDown();
|
||||
v = list.view();
|
||||
expect(v.selectedIndex).toBe(4);
|
||||
expect(v.page.page).toBe(1);
|
||||
|
||||
list.pageUp();
|
||||
expect(list.view().page.page).toBe(0);
|
||||
});
|
||||
|
||||
it('clamps the cursor at both ends', () => {
|
||||
const list = make({ initialIndex: 0 });
|
||||
list.moveUp(); // already at top
|
||||
expect(list.view().selectedIndex).toBe(0);
|
||||
|
||||
for (let i = 0; i < 20; i++) list.moveDown();
|
||||
expect(list.view().selectedIndex).toBe(9); // last item
|
||||
|
||||
list.pageDown(); // past the end stays clamped
|
||||
expect(list.view().selectedIndex).toBe(9);
|
||||
});
|
||||
|
||||
it('selected() returns the item under the clamped cursor', () => {
|
||||
const list = make({ initialIndex: 2 });
|
||||
expect(list.selected()).toBe('item02');
|
||||
list.moveDown();
|
||||
expect(list.selected()).toBe('item03');
|
||||
});
|
||||
|
||||
it('filters on the query, resets the cursor, and clearQuery restores the list', () => {
|
||||
const list = make({ initialIndex: 5, searchable: true });
|
||||
for (const ch of 'item09') list.handleKey(ch);
|
||||
|
||||
let v = list.view();
|
||||
expect(v.query).toBe('item09');
|
||||
expect(v.items).toContain('item09');
|
||||
expect(v.items).not.toContain('item00');
|
||||
expect(v.selectedIndex).toBe(0);
|
||||
expect(list.selected()).toBe(v.items[0]);
|
||||
|
||||
expect(list.clearQuery()).toBe(true);
|
||||
v = list.view();
|
||||
expect(v.query).toBe('');
|
||||
expect(v.items).toHaveLength(10);
|
||||
expect(list.clearQuery()).toBe(false); // nothing left to clear
|
||||
});
|
||||
|
||||
it('trims the query on Backspace', () => {
|
||||
const list = make({ searchable: true });
|
||||
for (const ch of 'item0') list.handleKey(ch);
|
||||
expect(list.view().query).toBe('item0');
|
||||
list.handleKey(BACKSPACE);
|
||||
expect(list.view().query).toBe('item');
|
||||
});
|
||||
|
||||
it('handleKey always consumes navigation but only edits the query when searchable', () => {
|
||||
const nav = make({ searchable: false });
|
||||
expect(nav.handleKey(UP)).toBe(true);
|
||||
expect(nav.handleKey(DOWN)).toBe(true);
|
||||
expect(nav.handleKey(PAGE_UP)).toBe(true);
|
||||
expect(nav.handleKey(PAGE_DOWN)).toBe(true);
|
||||
expect(nav.handleKey('a')).toBe(false); // not searchable → printable ignored
|
||||
expect(nav.handleKey(BACKSPACE)).toBe(false);
|
||||
expect(nav.view().query).toBe('');
|
||||
|
||||
const search = make({ searchable: true });
|
||||
expect(search.handleKey('a')).toBe(true);
|
||||
expect(search.handleKey(BACKSPACE)).toBe(true);
|
||||
expect(search.view().query).toBe('');
|
||||
});
|
||||
});
|
||||
|
|
@ -3,6 +3,7 @@ import { resolve } from 'node:path';
|
|||
import { defineConfig } from 'tsdown';
|
||||
|
||||
import { rawTextPlugin } from '../../build/raw-text-plugin.mjs';
|
||||
import { BUILT_IN_CATALOG_DEFINE, builtInCatalogDefine } from './scripts/built-in-catalog.mjs';
|
||||
|
||||
const appRoot = import.meta.dirname;
|
||||
|
||||
|
|
@ -24,6 +25,9 @@ export default defineConfig({
|
|||
alias: {
|
||||
'@': resolve(appRoot, 'src'),
|
||||
},
|
||||
define: {
|
||||
[BUILT_IN_CATALOG_DEFINE]: builtInCatalogDefine(),
|
||||
},
|
||||
deps: {
|
||||
alwaysBundle: [/^@moonshot-ai\//],
|
||||
neverBundle: [],
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { resolve } from 'node:path';
|
|||
import { defineConfig } from 'tsdown';
|
||||
|
||||
import { rawTextPlugin } from '../../build/raw-text-plugin.mjs';
|
||||
import { BUILT_IN_CATALOG_DEFINE, builtInCatalogDefine } from './scripts/built-in-catalog.mjs';
|
||||
|
||||
const appRoot = import.meta.dirname;
|
||||
const packageJson = JSON.parse(
|
||||
|
|
@ -43,6 +44,7 @@ export default defineConfig({
|
|||
'@': resolve(appRoot, 'src'),
|
||||
},
|
||||
define: {
|
||||
[BUILT_IN_CATALOG_DEFINE]: builtInCatalogDefine(),
|
||||
__KIMI_CODE_VERSION__: JSON.stringify(packageJson.version),
|
||||
__KIMI_CODE_CHANNEL__: JSON.stringify(process.env['KIMI_CODE_CHANNEL'] ?? ''),
|
||||
__KIMI_CODE_COMMIT__: JSON.stringify(process.env['KIMI_CODE_COMMIT'] ?? ''),
|
||||
|
|
|
|||
146
packages/kosong/src/catalog.ts
Normal file
146
packages/kosong/src/catalog.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import type { ModelCapability } from './capability';
|
||||
import type { ProviderType } from './providers';
|
||||
|
||||
/**
|
||||
* models.dev-style catalog: a public map of provider/model metadata. Callers
|
||||
* consume a snapshot of this shape to populate provider + model configuration
|
||||
* without hand-writing context windows or capabilities.
|
||||
*/
|
||||
export interface CatalogModelEntry {
|
||||
readonly id?: string;
|
||||
readonly name?: string;
|
||||
readonly family?: string;
|
||||
readonly limit?: { readonly context?: number; readonly output?: number };
|
||||
readonly tool_call?: boolean;
|
||||
readonly reasoning?: boolean;
|
||||
readonly modalities?: {
|
||||
readonly input?: readonly string[];
|
||||
readonly output?: readonly string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface CatalogProviderEntry {
|
||||
readonly id?: string;
|
||||
readonly name?: string;
|
||||
/** Base URL for the provider; may be empty (some SDKs hardcode it). */
|
||||
readonly api?: string;
|
||||
/** Env var names carrying credentials — surfaced as a hint by callers. */
|
||||
readonly env?: readonly string[];
|
||||
/** models.dev SDK package id; used to infer the wire type when `type` is absent. */
|
||||
readonly npm?: string;
|
||||
/** Explicit wire type extension; inferred from `npm`/`id` when absent. */
|
||||
readonly type?: string;
|
||||
readonly models?: Record<string, CatalogModelEntry>;
|
||||
}
|
||||
|
||||
/** Top-level catalog: `{ [providerId]: ProviderEntry }` (e.g. models.dev/api.json). */
|
||||
export type Catalog = Record<string, CatalogProviderEntry>;
|
||||
|
||||
/** A normalized catalog model: identity plus its {@link ModelCapability}. */
|
||||
export interface CatalogModel {
|
||||
readonly id: string;
|
||||
readonly name?: string;
|
||||
readonly maxOutputSize?: number;
|
||||
readonly capability: ModelCapability;
|
||||
}
|
||||
|
||||
const KNOWN_WIRE_TYPES = [
|
||||
'anthropic',
|
||||
'openai',
|
||||
'kimi',
|
||||
'google-genai',
|
||||
'openai_responses',
|
||||
'vertexai',
|
||||
] as const satisfies readonly ProviderType[];
|
||||
|
||||
function isWireType(value: unknown): value is ProviderType {
|
||||
return typeof value === 'string' && (KNOWN_WIRE_TYPES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function hasEmbeddingMarker(value: string | undefined): boolean {
|
||||
if (value === undefined) return false;
|
||||
const lower = value.toLowerCase();
|
||||
return lower.includes('embedding') || /(?:^|[-_/])embed(?:$|[-_/])/.test(lower);
|
||||
}
|
||||
|
||||
function isUsableChatModel(model: CatalogModelEntry): boolean {
|
||||
const outputModalities = model.modalities?.output;
|
||||
if (outputModalities !== undefined && !outputModalities.includes('text')) return false;
|
||||
return (
|
||||
!hasEmbeddingMarker(model.family) &&
|
||||
!hasEmbeddingMarker(model.id) &&
|
||||
!hasEmbeddingMarker(model.name)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a catalog provider entry to a supported wire type. Honors an
|
||||
* explicit `type`, otherwise infers from `npm`/`id`. Unknown providers return
|
||||
* `undefined` so callers can omit them instead of writing an invalid config.
|
||||
*/
|
||||
export function inferWireType(entry: CatalogProviderEntry): ProviderType | undefined {
|
||||
if (isWireType(entry.type)) return entry.type;
|
||||
const npm = (entry.npm ?? '').toLowerCase();
|
||||
const id = (entry.id ?? '').toLowerCase();
|
||||
if (npm.includes('anthropic') || id.includes('anthropic') || id.includes('claude')) {
|
||||
return 'anthropic';
|
||||
}
|
||||
if (id.includes('vertex')) return 'vertexai';
|
||||
if (npm.includes('google') || id.includes('google') || id.includes('gemini')) {
|
||||
return 'google-genai';
|
||||
}
|
||||
if (npm.includes('openai') || id.includes('openai')) return 'openai';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the base URL to store for a catalog provider, adapting the catalog's
|
||||
* `api` to the wire's SDK convention.
|
||||
*
|
||||
* models.dev `api` URLs are written for the SDK named in `npm` (e.g.
|
||||
* `@ai-sdk/anthropic`), whose base already includes the `/v1` version segment.
|
||||
* We route the `anthropic` wire through the official `@anthropic-ai/sdk`, which
|
||||
* appends `/v1/messages` itself — so a catalog `api` ending in `/v1` would POST
|
||||
* to `/v1/v1/messages` (404). Strip the trailing `/v1` for anthropic. OpenAI
|
||||
* family SDKs append `/chat/completions` to a `/v1` base, so those pass through.
|
||||
*/
|
||||
export function catalogBaseUrl(
|
||||
entry: CatalogProviderEntry,
|
||||
wire: ProviderType,
|
||||
): string | undefined {
|
||||
const api = entry.api;
|
||||
if (typeof api !== 'string' || api.length === 0) return undefined;
|
||||
if (wire === 'anthropic') return api.replace(/\/v1\/?$/, '');
|
||||
return api;
|
||||
}
|
||||
|
||||
/** Normalizes one catalog model entry into a {@link CatalogModel}; skips invalid entries. */
|
||||
export function catalogModelToCapability(model: CatalogModelEntry): CatalogModel | undefined {
|
||||
if (typeof model.id !== 'string' || model.id.length === 0) return undefined;
|
||||
const context = model.limit?.context;
|
||||
if (typeof context !== 'number' || !Number.isInteger(context) || context <= 0) return undefined;
|
||||
if (!isUsableChatModel(model)) return undefined;
|
||||
const inputs = model.modalities?.input ?? [];
|
||||
const output = model.limit?.output;
|
||||
return {
|
||||
id: model.id,
|
||||
name: typeof model.name === 'string' && model.name.length > 0 ? model.name : undefined,
|
||||
maxOutputSize: typeof output === 'number' && output > 0 ? output : undefined,
|
||||
capability: {
|
||||
image_in: inputs.includes('image'),
|
||||
video_in: inputs.includes('video'),
|
||||
audio_in: inputs.includes('audio'),
|
||||
thinking: Boolean(model.reasoning),
|
||||
tool_use: model.tool_call ?? true,
|
||||
max_context_tokens: context,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Extracts the valid, normalized models from a catalog provider entry. */
|
||||
export function catalogProviderModels(entry: CatalogProviderEntry): CatalogModel[] {
|
||||
const models = entry.models ?? {};
|
||||
return Object.values(models)
|
||||
.map((model) => catalogModelToCapability(model))
|
||||
.filter((model): model is CatalogModel => model !== undefined);
|
||||
}
|
||||
|
|
@ -26,12 +26,21 @@ export type {
|
|||
// Provider interfaces
|
||||
export * from './provider';
|
||||
export { createProvider } from './providers';
|
||||
export type { ProviderConfig } from './providers';
|
||||
export type { ProviderConfig, ProviderType } from './providers';
|
||||
|
||||
// Model capability matrix
|
||||
export { UNKNOWN_CAPABILITY, isUnknownCapability } from './capability';
|
||||
export type { ModelCapability } from './capability';
|
||||
|
||||
// Model catalog (models.dev-style) metadata
|
||||
export {
|
||||
catalogBaseUrl,
|
||||
catalogModelToCapability,
|
||||
catalogProviderModels,
|
||||
inferWireType,
|
||||
} from './catalog';
|
||||
export type { Catalog, CatalogModel, CatalogModelEntry, CatalogProviderEntry } from './catalog';
|
||||
|
||||
// Core functions
|
||||
export { generate } from './generate';
|
||||
export type { GenerateCallbacks, GenerateResult } from './generate';
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export type ProviderConfig =
|
|||
| ({ type: 'openai_responses' } & OpenAIResponsesOptions)
|
||||
| ({ type: 'vertexai' } & GoogleGenAIOptions);
|
||||
|
||||
export type ProviderType = ProviderConfig['type'];
|
||||
|
||||
export function createProvider(config: ProviderConfig): ChatProvider {
|
||||
switch (config.type) {
|
||||
case 'anthropic':
|
||||
|
|
|
|||
139
packages/kosong/test/catalog.test.ts
Normal file
139
packages/kosong/test/catalog.test.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
catalogBaseUrl,
|
||||
catalogModelToCapability,
|
||||
catalogProviderModels,
|
||||
inferWireType,
|
||||
} from '../src/catalog';
|
||||
|
||||
describe('inferWireType', () => {
|
||||
it('honors an explicit valid type', () => {
|
||||
expect(inferWireType({ id: 'x', type: 'openai_responses' })).toBe('openai_responses');
|
||||
});
|
||||
|
||||
it('infers anthropic from npm or id', () => {
|
||||
expect(inferWireType({ id: 'anthropic', npm: '@ai-sdk/anthropic' })).toBe('anthropic');
|
||||
expect(inferWireType({ id: 'my-claude' })).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('infers google-genai and vertexai', () => {
|
||||
expect(inferWireType({ id: 'gemini', npm: '@ai-sdk/google' })).toBe('google-genai');
|
||||
expect(inferWireType({ id: 'google-vertex' })).toBe('vertexai');
|
||||
});
|
||||
|
||||
it('returns undefined for unknown / invalid wire types', () => {
|
||||
expect(inferWireType({ id: 'some-proxy' })).toBeUndefined();
|
||||
expect(inferWireType({ id: 'x', type: 'not-a-wire' })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalogBaseUrl', () => {
|
||||
it('strips a trailing /v1 for anthropic so the official SDK does not double it', () => {
|
||||
expect(catalogBaseUrl({ id: 'k', api: 'https://api.kimi.com/coding/v1' }, 'anthropic')).toBe(
|
||||
'https://api.kimi.com/coding',
|
||||
);
|
||||
expect(catalogBaseUrl({ id: 'k', api: 'https://api.kimi.com/coding/v1/' }, 'anthropic')).toBe(
|
||||
'https://api.kimi.com/coding',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves anthropic base URLs without a bare /v1 suffix untouched', () => {
|
||||
expect(catalogBaseUrl({ id: 'a', api: 'https://api.anthropic.com' }, 'anthropic')).toBe(
|
||||
'https://api.anthropic.com',
|
||||
);
|
||||
expect(catalogBaseUrl({ id: 'a', api: 'https://host/v1beta' }, 'anthropic')).toBe(
|
||||
'https://host/v1beta',
|
||||
);
|
||||
});
|
||||
|
||||
it('passes openai-family base URLs through unchanged (SDK appends /chat/completions)', () => {
|
||||
expect(catalogBaseUrl({ id: 'o', api: 'https://api.openai.com/v1' }, 'openai')).toBe(
|
||||
'https://api.openai.com/v1',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined for a missing or empty api', () => {
|
||||
expect(catalogBaseUrl({ id: 'x' }, 'anthropic')).toBeUndefined();
|
||||
expect(catalogBaseUrl({ id: 'x', api: '' }, 'openai')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalogModelToCapability', () => {
|
||||
it('maps modalities and limits into a ModelCapability', () => {
|
||||
expect(
|
||||
catalogModelToCapability({
|
||||
id: 'm',
|
||||
name: 'M',
|
||||
limit: { context: 200000, output: 64000 },
|
||||
tool_call: true,
|
||||
reasoning: true,
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'm',
|
||||
name: 'M',
|
||||
maxOutputSize: 64000,
|
||||
capability: {
|
||||
image_in: true,
|
||||
video_in: false,
|
||||
audio_in: false,
|
||||
thinking: true,
|
||||
tool_use: true,
|
||||
max_context_tokens: 200000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults tool_use to true and skips models without a positive context', () => {
|
||||
expect(catalogModelToCapability({ id: 'm', limit: { context: 1000 } })?.capability.tool_use).toBe(
|
||||
true,
|
||||
);
|
||||
expect(catalogModelToCapability({ id: 'm' })).toBeUndefined();
|
||||
expect(catalogModelToCapability({ id: 'm', limit: { context: 0 } })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips embedding and non-text-output models that cannot serve as chat defaults', () => {
|
||||
expect(
|
||||
catalogModelToCapability({
|
||||
id: 'text-embedding-3-large',
|
||||
name: 'text-embedding-3-large',
|
||||
family: 'text-embedding',
|
||||
limit: { context: 8192, output: 1536 },
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
catalogModelToCapability({
|
||||
id: 'grok-imagine-image',
|
||||
name: 'Grok Imagine Image',
|
||||
family: 'grok',
|
||||
limit: { context: 8000 },
|
||||
modalities: { input: ['text', 'image'], output: ['image', 'pdf'] },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
catalogModelToCapability({
|
||||
id: 'mimo-v2-tts',
|
||||
name: 'MiMo-V2-TTS',
|
||||
family: 'mimo',
|
||||
limit: { context: 8192, output: 16384 },
|
||||
modalities: { input: ['text'], output: ['audio'] },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalogProviderModels', () => {
|
||||
it('extracts only valid models from a provider entry', () => {
|
||||
const models = catalogProviderModels({
|
||||
id: 'p',
|
||||
models: {
|
||||
good: { id: 'good', limit: { context: 1000 } },
|
||||
bad: { id: 'bad' },
|
||||
},
|
||||
});
|
||||
expect(models).toHaveLength(1);
|
||||
expect(models[0]?.id).toBe('good');
|
||||
});
|
||||
});
|
||||
124
packages/node-sdk/src/catalog.ts
Normal file
124
packages/node-sdk/src/catalog.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import type { KimiConfig, ModelAlias } from '@moonshot-ai/agent-core';
|
||||
import {
|
||||
catalogBaseUrl,
|
||||
catalogProviderModels,
|
||||
inferWireType,
|
||||
type Catalog,
|
||||
type CatalogModel,
|
||||
type CatalogProviderEntry,
|
||||
type ModelCapability,
|
||||
type ProviderType,
|
||||
} from '@moonshot-ai/kosong';
|
||||
|
||||
export { catalogBaseUrl, catalogProviderModels, inferWireType };
|
||||
export type { Catalog, CatalogModel, CatalogProviderEntry };
|
||||
|
||||
export const DEFAULT_CATALOG_URL = 'https://models.dev/api.json';
|
||||
|
||||
export class CatalogFetchError extends Error {
|
||||
readonly status: number;
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetches a models.dev-style catalog. Public endpoint, no credentials needed. */
|
||||
export async function fetchCatalog(
|
||||
url: string,
|
||||
signal?: AbortSignal,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<Catalog> {
|
||||
const res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal });
|
||||
if (!res.ok) {
|
||||
throw new CatalogFetchError(`Failed to fetch catalog (HTTP ${res.status}).`, res.status);
|
||||
}
|
||||
const payload: unknown = await res.json();
|
||||
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
|
||||
throw new Error(`Unexpected catalog response from ${url}.`);
|
||||
}
|
||||
return payload as Catalog;
|
||||
}
|
||||
|
||||
function capabilityToStrings(capability: ModelCapability): string[] | undefined {
|
||||
const caps: string[] = [];
|
||||
if (capability.image_in) caps.push('image_in');
|
||||
if (capability.video_in) caps.push('video_in');
|
||||
if (capability.audio_in) caps.push('audio_in');
|
||||
if (capability.thinking) caps.push('thinking');
|
||||
if (capability.tool_use) caps.push('tool_use');
|
||||
return caps.length > 0 ? caps : undefined;
|
||||
}
|
||||
|
||||
/** Builds a kimi-code model alias from a normalized catalog model. */
|
||||
export function catalogModelToAlias(providerId: string, model: CatalogModel): ModelAlias {
|
||||
return {
|
||||
provider: providerId,
|
||||
model: model.id,
|
||||
maxContextSize: model.capability.max_context_tokens,
|
||||
maxOutputSize: model.maxOutputSize,
|
||||
capabilities: capabilityToStrings(model.capability),
|
||||
displayName: model.name,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApplyCatalogProviderOptions {
|
||||
readonly providerId: string;
|
||||
readonly wire: ProviderType;
|
||||
readonly baseUrl?: string;
|
||||
readonly apiKey: string;
|
||||
readonly models: readonly CatalogModel[];
|
||||
readonly selectedModelId: string;
|
||||
readonly thinking: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an optional pruned models.dev catalog string — typically the
|
||||
* `__KIMI_CODE_BUILT_IN_CATALOG__` constant injected by tsdown at build
|
||||
* time. Returns `undefined` when the argument is missing or invalid.
|
||||
*/
|
||||
export function loadBuiltInCatalog(text?: string): Catalog | undefined {
|
||||
if (typeof text !== 'string' || text.length === 0) return undefined;
|
||||
try {
|
||||
return JSON.parse(text) as Catalog;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a catalog-selected provider and its model aliases into `config` and
|
||||
* marks it the default. Model metadata (context, output limit, capabilities)
|
||||
* comes from the catalog, so the user does not hand-write it. Returns the
|
||||
* default model key.
|
||||
*
|
||||
* NOTE: the same-provider cleanup below mutates the passed-in `config` only.
|
||||
* It clears stale aliases on disk solely when the caller overwrites the whole
|
||||
* config. Callers persisting via `setConfig` — a deep-merge patch that cannot
|
||||
* delete keys — must call `removeProvider` first, or removed aliases reappear
|
||||
* after the merge.
|
||||
*/
|
||||
export function applyCatalogProvider(
|
||||
config: KimiConfig,
|
||||
options: ApplyCatalogProviderOptions,
|
||||
): { defaultModel: string } {
|
||||
config.providers[options.providerId] = {
|
||||
type: options.wire,
|
||||
baseUrl: options.baseUrl,
|
||||
apiKey: options.apiKey,
|
||||
};
|
||||
|
||||
const models = config.models ?? {};
|
||||
for (const [key, alias] of Object.entries(models)) {
|
||||
if (alias.provider === options.providerId) delete models[key];
|
||||
}
|
||||
for (const model of options.models) {
|
||||
models[`${options.providerId}/${model.id}`] = catalogModelToAlias(options.providerId, model);
|
||||
}
|
||||
config.models = models;
|
||||
|
||||
const defaultModel = `${options.providerId}/${options.selectedModelId}`;
|
||||
config.defaultModel = defaultModel;
|
||||
config.defaultThinking = options.thinking;
|
||||
return { defaultModel };
|
||||
}
|
||||
|
|
@ -2,6 +2,24 @@ export { KimiHarness } from '#/kimi-harness';
|
|||
export { Session } from '#/session';
|
||||
export { KimiAuthFacade } from '#/auth';
|
||||
|
||||
export {
|
||||
applyCatalogProvider,
|
||||
catalogBaseUrl,
|
||||
catalogModelToAlias,
|
||||
catalogProviderModels,
|
||||
CatalogFetchError,
|
||||
DEFAULT_CATALOG_URL,
|
||||
fetchCatalog,
|
||||
inferWireType,
|
||||
loadBuiltInCatalog,
|
||||
} from '#/catalog';
|
||||
export type {
|
||||
ApplyCatalogProviderOptions,
|
||||
Catalog,
|
||||
CatalogModel,
|
||||
CatalogProviderEntry,
|
||||
} from '#/catalog';
|
||||
|
||||
export {
|
||||
ErrorCodes,
|
||||
KimiError,
|
||||
|
|
|
|||
114
packages/node-sdk/test/catalog.test.ts
Normal file
114
packages/node-sdk/test/catalog.test.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import type { KimiConfig } from '@moonshot-ai/agent-core';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
applyCatalogProvider,
|
||||
catalogModelToAlias,
|
||||
CatalogFetchError,
|
||||
fetchCatalog,
|
||||
type CatalogModel,
|
||||
} from '../src/catalog';
|
||||
|
||||
function catalogResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const model: CatalogModel = {
|
||||
id: 'm1',
|
||||
name: 'M1',
|
||||
maxOutputSize: 64000,
|
||||
capability: {
|
||||
image_in: true,
|
||||
video_in: false,
|
||||
audio_in: false,
|
||||
thinking: true,
|
||||
tool_use: true,
|
||||
max_context_tokens: 200000,
|
||||
},
|
||||
};
|
||||
|
||||
describe('fetchCatalog', () => {
|
||||
it('fetches and returns the catalog map', async () => {
|
||||
const catalog = { anthropic: { id: 'anthropic', models: { x: { id: 'x', limit: { context: 1000 } } } } };
|
||||
const fetchMock = vi.fn(async () => catalogResponse(catalog));
|
||||
const result = await fetchCatalog('https://x/api.json', undefined, fetchMock as unknown as typeof fetch);
|
||||
expect(result).toEqual(catalog);
|
||||
});
|
||||
|
||||
it('throws CatalogFetchError on HTTP error', async () => {
|
||||
const fetchMock = vi.fn(async () => catalogResponse('no', 500));
|
||||
await expect(
|
||||
fetchCatalog('https://x', undefined, fetchMock as unknown as typeof fetch),
|
||||
).rejects.toBeInstanceOf(CatalogFetchError);
|
||||
});
|
||||
|
||||
it('throws on a non-object payload', async () => {
|
||||
const fetchMock = vi.fn(async () => catalogResponse([1, 2]));
|
||||
await expect(
|
||||
fetchCatalog('https://x', undefined, fetchMock as unknown as typeof fetch),
|
||||
).rejects.toThrow(/Unexpected catalog response/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalogModelToAlias', () => {
|
||||
it('flattens a catalog model capability into alias fields', () => {
|
||||
expect(catalogModelToAlias('anthropic', model)).toEqual({
|
||||
provider: 'anthropic',
|
||||
model: 'm1',
|
||||
maxContextSize: 200000,
|
||||
maxOutputSize: 64000,
|
||||
capabilities: ['image_in', 'thinking', 'tool_use'],
|
||||
displayName: 'M1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyCatalogProvider', () => {
|
||||
it('writes provider, model aliases, and defaults', () => {
|
||||
const config = { providers: {} } as KimiConfig;
|
||||
const result = applyCatalogProvider(config, {
|
||||
providerId: 'anthropic',
|
||||
wire: 'anthropic',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
apiKey: 'sk',
|
||||
models: [model],
|
||||
selectedModelId: 'm1',
|
||||
thinking: true,
|
||||
});
|
||||
|
||||
expect(result.defaultModel).toBe('anthropic/m1');
|
||||
expect(config.providers['anthropic']).toMatchObject({ type: 'anthropic', apiKey: 'sk' });
|
||||
expect(config.models?.['anthropic/m1']).toMatchObject({
|
||||
provider: 'anthropic',
|
||||
model: 'm1',
|
||||
maxContextSize: 200000,
|
||||
});
|
||||
expect(config.defaultModel).toBe('anthropic/m1');
|
||||
expect(config.defaultThinking).toBe(true);
|
||||
});
|
||||
|
||||
it('clears stale aliases for the same provider but keeps others', () => {
|
||||
const config = {
|
||||
providers: { anthropic: { type: 'anthropic', apiKey: 'old' } },
|
||||
models: {
|
||||
'anthropic/stale': { provider: 'anthropic', model: 'stale', maxContextSize: 1 },
|
||||
'other/keep': { provider: 'other', model: 'keep', maxContextSize: 1 },
|
||||
},
|
||||
} as unknown as KimiConfig;
|
||||
|
||||
applyCatalogProvider(config, {
|
||||
providerId: 'anthropic',
|
||||
wire: 'anthropic',
|
||||
apiKey: 'new',
|
||||
models: [model],
|
||||
selectedModelId: 'm1',
|
||||
thinking: false,
|
||||
});
|
||||
|
||||
expect(config.models?.['anthropic/stale']).toBeUndefined();
|
||||
expect(config.models?.['other/keep']).toBeDefined();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue