diff --git a/.changeset/connect-model-catalog.md b/.changeset/connect-model-catalog.md new file mode 100644 index 0000000..8d55db8 --- /dev/null +++ b/.changeset/connect-model-catalog.md @@ -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. diff --git a/.changeset/connect-picker-search-pagination.md b/.changeset/connect-picker-search-pagination.md new file mode 100644 index 0000000..144a3b4 --- /dev/null +++ b/.changeset/connect-picker-search-pagination.md @@ -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. diff --git a/.changeset/model-picker-empty-hint.md b/.changeset/model-picker-empty-hint.md new file mode 100644 index 0000000..2b37cac --- /dev/null +++ b/.changeset/model-picker-empty-hint.md @@ -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. diff --git a/.github/workflows/_native-build.yml b/.github/workflows/_native-build.yml index 439d235..bc190ed 100644 --- a/.github/workflows/_native-build.yml +++ b/.github/workflows/_native-build.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc0b9a8..421c1fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 01f4483..32c0eee 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -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", diff --git a/apps/kimi-code/scripts/built-in-catalog.mjs b/apps/kimi-code/scripts/built-in-catalog.mjs new file mode 100644 index 0000000..c614b01 --- /dev/null +++ b/apps/kimi-code/scripts/built-in-catalog.mjs @@ -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')); +} diff --git a/apps/kimi-code/scripts/native/build.mjs b/apps/kimi-code/scripts/native/build.mjs index ec51d23..3a02d6d 100644 --- a/apps/kimi-code/scripts/native/build.mjs +++ b/apps/kimi-code/scripts/native/build.mjs @@ -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(); diff --git a/apps/kimi-code/scripts/update-catalog.mjs b/apps/kimi-code/scripts/update-catalog.mjs new file mode 100644 index 0000000..48f2fd7 --- /dev/null +++ b/apps/kimi-code/scripts/update-catalog.mjs @@ -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); +}); diff --git a/apps/kimi-code/src/built-in-catalog.ts b/apps/kimi-code/src/built-in-catalog.ts new file mode 100644 index 0000000..1511de3 --- /dev/null +++ b/apps/kimi-code/src/built-in-catalog.ts @@ -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; diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 5195bf5..83bece5 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -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'], diff --git a/apps/kimi-code/src/tui/components/chrome/welcome.ts b/apps/kimi-code/src/tui/components/chrome/welcome.ts index e75d46d..69993e6 100644 --- a/apps/kimi-code/src/tui/components/chrome/welcome.ts +++ b/apps/kimi-code/src/tui/components/chrome/welcome.ts @@ -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 = [ diff --git a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts index 66bca1a..b5e7b8b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts @@ -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; 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)); } diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index 600d0ca..adf95a5 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -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; 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 => diff --git a/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts b/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts index d01b26e..c889b12 100644 --- a/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/platform-selector.ts @@ -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 { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 91ea254..83787f7 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -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'; @@ -207,6 +220,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 { argsRecord, formatErrorMessage, @@ -1425,6 +1439,9 @@ export class KimiTUI { case 'login': await this.handleLoginCommand(); return; + case 'connect': + await this.handleConnectCommand(args); + return; case 'logout': await this.handleLogoutCommand(); return; @@ -4321,7 +4338,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( @@ -4331,6 +4351,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); @@ -5068,6 +5089,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 { + 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. @@ -5136,7 +5273,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(); @@ -5169,6 +5310,42 @@ export class KimiTUI { }); } + private promptCatalogProviderSelection(catalog: Catalog): Promise { + 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 { return new Promise((resolve) => { const dialog = new ApiKeyInputDialogComponent( @@ -5183,39 +5360,56 @@ export class KimiTUI { }); } - private promptModelSelectionForOpenPlatform( + private async promptModelSelectionForOpenPlatform( models: ManagedKimiCodeModelInfo[], platform: OpenPlatformDefinition, ): Promise<{ model: ManagedKimiCodeModelInfo; thinking: boolean } | undefined> { + const modelDict: Record = {}; + 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 = {}; + 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, + ): Promise<{ alias: string; thinking: boolean } | undefined> { return new Promise((resolve) => { - const modelDict: Record = {}; - 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(); diff --git a/apps/kimi-code/src/tui/utils/connect-catalog.ts b/apps/kimi-code/src/tui/utils/connect-catalog.ts new file mode 100644 index 0000000..341ec93 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/connect-catalog.ts @@ -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, + }, + }; +} diff --git a/apps/kimi-code/src/tui/utils/paging.ts b/apps/kimi-code/src/tui/utils/paging.ts new file mode 100644 index 0000000..797124a --- /dev/null +++ b/apps/kimi-code/src/tui/utils/paging.ts @@ -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 }; +} diff --git a/apps/kimi-code/src/tui/utils/printable-key.ts b/apps/kimi-code/src/tui/utils/printable-key.ts index 1b09773..7daa36a 100644 --- a/apps/kimi-code/src/tui/utils/printable-key.ts +++ b/apps/kimi-code/src/tui/utils/printable-key.ts @@ -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; +} diff --git a/apps/kimi-code/src/tui/utils/searchable-list.ts b/apps/kimi-code/src/tui/utils/searchable-list.ts new file mode 100644 index 0000000..b5b7343 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/searchable-list.ts @@ -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 { + 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 { + /** 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 { + 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) { + 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 { + 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; + } +} diff --git a/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts index 9f6d453..d41d68a 100644 --- a/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts @@ -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 { + const models: Record = {}; + 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, 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'); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index e3b683a..ac21c0c 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -36,6 +36,21 @@ interface FeedbackDriver extends MessageDriver { promptFeedbackInput(): Promise; } +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: { @@ -1059,6 +1074,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'); @@ -1110,6 +1131,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' })); diff --git a/apps/kimi-code/test/tui/utils/connect-catalog.test.ts b/apps/kimi-code/test/tui/utils/connect-catalog.test.ts new file mode 100644 index 0000000..1de4736 --- /dev/null +++ b/apps/kimi-code/test/tui/utils/connect-catalog.test.ts @@ -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 }); + } + }); +}); diff --git a/apps/kimi-code/test/tui/utils/paging.test.ts b/apps/kimi-code/test/tui/utils/paging.test.ts new file mode 100644 index 0000000..4546858 --- /dev/null +++ b/apps/kimi-code/test/tui/utils/paging.test.ts @@ -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 }); + }); +}); diff --git a/apps/kimi-code/test/tui/utils/searchable-list.test.ts b/apps/kimi-code/test/tui/utils/searchable-list.test.ts new file mode 100644 index 0000000..170b899 --- /dev/null +++ b/apps/kimi-code/test/tui/utils/searchable-list.test.ts @@ -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> = {}): SearchableList { + return new SearchableList({ + 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(''); + }); +}); diff --git a/apps/kimi-code/tsdown.config.ts b/apps/kimi-code/tsdown.config.ts index 93b0659..918b621 100644 --- a/apps/kimi-code/tsdown.config.ts +++ b/apps/kimi-code/tsdown.config.ts @@ -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: [], diff --git a/apps/kimi-code/tsdown.native.config.ts b/apps/kimi-code/tsdown.native.config.ts index eec94d3..bf4e16f 100644 --- a/apps/kimi-code/tsdown.native.config.ts +++ b/apps/kimi-code/tsdown.native.config.ts @@ -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'] ?? ''), diff --git a/packages/kosong/src/catalog.ts b/packages/kosong/src/catalog.ts new file mode 100644 index 0000000..2206714 --- /dev/null +++ b/packages/kosong/src/catalog.ts @@ -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; +} + +/** Top-level catalog: `{ [providerId]: ProviderEntry }` (e.g. models.dev/api.json). */ +export type Catalog = Record; + +/** 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); +} diff --git a/packages/kosong/src/index.ts b/packages/kosong/src/index.ts index 224a55c..6dac509 100644 --- a/packages/kosong/src/index.ts +++ b/packages/kosong/src/index.ts @@ -27,12 +27,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'; diff --git a/packages/kosong/src/providers/index.ts b/packages/kosong/src/providers/index.ts index 4d59408..c677f6b 100644 --- a/packages/kosong/src/providers/index.ts +++ b/packages/kosong/src/providers/index.ts @@ -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': diff --git a/packages/kosong/test/catalog.test.ts b/packages/kosong/test/catalog.test.ts new file mode 100644 index 0000000..a6f73ef --- /dev/null +++ b/packages/kosong/test/catalog.test.ts @@ -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'); + }); +}); diff --git a/packages/node-sdk/src/catalog.ts b/packages/node-sdk/src/catalog.ts new file mode 100644 index 0000000..fa04423 --- /dev/null +++ b/packages/node-sdk/src/catalog.ts @@ -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 { + 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 }; +} diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index f1801ef..a3136fb 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -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, diff --git a/packages/node-sdk/test/catalog.test.ts b/packages/node-sdk/test/catalog.test.ts new file mode 100644 index 0000000..9d9b8f4 --- /dev/null +++ b/packages/node-sdk/test/catalog.test.ts @@ -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(); + }); +});