feat(tui): add /provider command, custom registry import, and tabbed model selector (#264)

* feat(tui): add /provider command, custom registry import, and tabbed model selector

- add "/provider" slash command for managing AI providers with CRUD UI
- add custom registry import via api.json URL and Bearer token
- introduce tabbed model selector grouped by provider
- add fetchCustomRegistry and applyCustomRegistryProvider in oauth package
- replace deprecated "/connect" command with unified "/provider" flow
- update provider and slash-command documentation

* feat(tui): background provider model refresh at startup

- add `refreshAllProviderModels` utility supporting managed OAuth, open platforms and custom registries
- wire background refresh into `KimiTUI` startup via `AuthFlowController`
- add `providerSwitchHint` option to `ModelSelector` and enable it in `TabbedModelSelector`
- update `TabbedModelSelector` hint wording from "switch" to "provider"
This commit is contained in:
Haozhe 2026-06-01 18:46:40 +08:00 committed by GitHub
parent a1dfbfeb16
commit 42bb9141d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2580 additions and 310 deletions

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/kimi-code": minor
"@moonshot-ai/kimi-code-oauth": minor
---
Add `/provider` command for managing AI providers, support custom registry imports, and introduce a tabbed model selector.

View file

@ -1,7 +1,7 @@
import type { PermissionMode, Session } from '@moonshot-ai/kimi-code-sdk';
import { EditorSelectorComponent } from '../components/dialogs/editor-selector';
import { ModelSelectorComponent } from '../components/dialogs/model-selector';
import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector';
import { PermissionSelectorComponent } from '../components/dialogs/permission-selector';
import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector';
import { ThemeSelectorComponent } from '../components/dialogs/theme-selector';
@ -256,13 +256,12 @@ export function showModelPicker(host: SlashCommandHost, selectedValue: string =
return;
}
host.mountEditorReplacement(
new ModelSelectorComponent({
new TabbedModelSelectorComponent({
models: host.state.appState.availableModels,
currentValue: host.state.appState.model,
selectedValue,
currentThinking: host.state.appState.thinking,
colors: host.state.theme.colors,
searchable: true,
onSelect: ({ alias, thinking }) => {
host.restoreEditor();
void performModelSwitch(host, alias, thinking);

View file

@ -20,7 +20,7 @@ import type { TasksBrowserController } from '../controllers/tasks-browser';
import type { AppState, LoginProgressSpinnerHandle, QueuedMessage } from '../types';
import type { TUIState } from '../tui-state';
import { handleConnectCommand, handleLoginCommand, handleLogoutCommand } from './auth';
import { handleLoginCommand, handleLogoutCommand } from './auth';
import {
handleAutoCommand,
handleCompactCommand,
@ -33,6 +33,7 @@ import {
showPermissionPicker,
showSettingsSelector,
} from './config';
import { handleProviderCommand } from './provider';
import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info';
import { handlePluginsCommand } from './plugins';
import {
@ -228,6 +229,9 @@ async function handleBuiltInSlashCommand(
case 'model':
handleModelCommand(host, args);
return;
case 'provider':
await handleProviderCommand(host);
return;
case 'permission':
showPermissionPicker(host);
return;
@ -273,9 +277,6 @@ async function handleBuiltInSlashCommand(
case 'login':
await handleLoginCommand(host);
return;
case 'connect':
await handleConnectCommand(host, args);
return;
case 'logout':
await handleLogoutCommand(host);
return;

View file

@ -0,0 +1,357 @@
import {
applyCustomRegistryProvider,
fetchCustomRegistry,
type CustomRegistrySource,
} from '@moonshot-ai/kimi-code-oauth';
import {
applyCatalogProvider,
catalogBaseUrl,
catalogProviderModels,
CatalogFetchError,
DEFAULT_CATALOG_URL,
fetchCatalog,
inferWireType,
type Catalog,
} from '@moonshot-ai/kimi-code-sdk';
import { ChoicePickerComponent } from '../components/dialogs/choice-picker';
import {
CustomRegistryImportDialogComponent,
type CustomRegistryImportResult,
} from '../components/dialogs/custom-registry-import';
import {
ProviderManagerComponent,
type ProviderManagerOptions,
} from '../components/dialogs/provider-manager';
import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector';
import { DEFAULT_OAUTH_PROVIDER_NAME } from '../constant/kimi-tui';
import { formatErrorMessage } from '../utils/event-payload';
import {
promptApiKey,
promptCatalogProviderSelection,
} from './prompts';
import type { SlashCommandHost } from './dispatch';
// ---------------------------------------------------------------------------
// /provider command
// ---------------------------------------------------------------------------
export async function handleProviderCommand(host: SlashCommandHost): Promise<void> {
const options = buildProviderManagerOptions(host);
const component = new ProviderManagerComponent(options);
host.mountEditorReplacement(component);
}
function buildProviderManagerOptions(host: SlashCommandHost): ProviderManagerOptions {
const activeProviderId =
host.state.appState.availableModels[host.state.appState.model]?.provider;
return {
providers: host.state.appState.availableProviders,
activeProviderId,
colors: host.state.theme.colors,
onAdd: () => {
void handleProviderAdd(host);
},
onDeleteSource: (providerIds) => {
void handleProviderManagerDeleteSource(host, providerIds);
},
onClose: () => {
host.restoreEditor();
},
};
}
async function handleProviderManagerDeleteSource(
host: SlashCommandHost,
providerIds: readonly string[],
): Promise<void> {
for (const providerId of providerIds) {
try {
await handleProviderDelete(host, providerId);
} catch (error) {
const msg = formatErrorMessage(error);
host.showError(`Failed to delete provider ${providerId}: ${msg}`);
}
}
reopenProviderManager(host);
}
async function handleProviderDelete(host: SlashCommandHost, providerId: string): Promise<void> {
if (providerId === DEFAULT_OAUTH_PROVIDER_NAME) {
await host.harness.auth.logout(DEFAULT_OAUTH_PROVIDER_NAME);
await host.authFlow.refreshConfigAfterLogout();
await host.authFlow.clearActiveSessionAfterLogout();
return;
}
const activeProvider =
host.state.appState.availableModels[host.state.appState.model]?.provider;
const config = await host.harness.removeProvider(providerId);
if (activeProvider === providerId) {
await host.authFlow.refreshConfigAfterLogout();
await host.authFlow.clearActiveSessionAfterLogout();
} else {
host.setAppState({
availableProviders: config.providers ?? {},
availableModels: config.models ?? {},
});
}
}
async function handleProviderAdd(host: SlashCommandHost): Promise<void> {
const source = await promptProviderAddSource(host);
if (source === undefined) {
reopenProviderManager(host);
return;
}
if (source === 'known') {
await handleCatalogProviderAdd(host);
return;
}
const handled = await handleCustomRegistryAddViaDialog(host);
if (!handled) {
reopenProviderManager(host);
}
}
function reopenProviderManager(host: SlashCommandHost): void {
const options = buildProviderManagerOptions(host);
const component = new ProviderManagerComponent(options);
host.mountEditorReplacement(component);
}
function promptProviderAddSource(
host: SlashCommandHost,
): Promise<'known' | 'custom' | undefined> {
return new Promise((resolve) => {
const picker = new ChoicePickerComponent({
title: 'Add provider',
options: [
{ value: 'known', label: 'Known third-party provider' },
{ value: 'custom', label: 'Custom registry (api.json)' },
],
colors: host.state.theme.colors,
onSelect: (value) => {
host.restoreEditor();
resolve(value === 'known' || value === 'custom' ? value : undefined);
},
onCancel: () => {
host.restoreEditor();
resolve(undefined);
},
});
host.mountEditorReplacement(picker);
});
}
async function handleCatalogProviderAdd(host: SlashCommandHost): Promise<void> {
const controller = new AbortController();
const cancel = (): void => {
controller.abort();
};
host.cancelInFlight = cancel;
const spinner = host.showLoginProgressSpinner(`Fetching catalog from ${DEFAULT_CATALOG_URL}`);
let catalog: Catalog | undefined;
try {
catalog = await fetchCatalog(DEFAULT_CATALOG_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})` : '';
spinner.stop({ ok: false, label: 'Failed to load catalog.' });
host.showError(`Failed to fetch catalog${hint}: ${formatErrorMessage(error)}`);
}
} finally {
if (host.cancelInFlight === cancel) host.cancelInFlight = undefined;
}
if (catalog === undefined) return;
const providerId = await promptCatalogProviderSelection(host, catalog);
if (providerId === undefined) return;
const entry = catalog[providerId];
if (entry === undefined) return;
const models = catalogProviderModels(entry);
if (models.length === 0) {
host.showError(`Provider "${providerId}" has no usable models in this catalog.`);
return;
}
const apiKey = await promptApiKey(host, entry.name ?? providerId);
if (apiKey === undefined) return;
const wire = inferWireType(entry);
if (wire === undefined) {
host.showError(`Provider "${providerId}" has unsupported wire type.`);
return;
}
const baseUrl = catalogBaseUrl(entry, wire);
// Persist the provider and all its models immediately after the api key is
// entered. The model selector that follows is just a convenience to pick the
// default model; ESC leaves the provider in place without a default selection.
const existingConfig = await host.harness.getConfig();
if (existingConfig.providers[providerId] !== undefined) {
await host.harness.removeProvider(providerId);
}
const config = await host.harness.getConfig();
applyCatalogProvider(config, {
providerId,
wire,
baseUrl,
apiKey,
models,
selectedModelId: '', // no default yet; user picks in the model selector
thinking: false, // will be resolved by the model selector
});
await host.harness.setConfig({
providers: config.providers,
models: config.models,
});
await host.authFlow.refreshConfigAfterLogin();
host.track('connect', { provider: providerId, method: 'catalog' });
host.showStatus(`Provider added: ${entry.name ?? providerId}`);
// Build a merged model dictionary that includes existing models plus the
// newly-persisted provider's models, so the TabbedModelSelectorComponent
// shows all tabs.
const stateModels = await host.harness.getConfig().then((c) => c.models ?? {});
const mergedModels = { ...stateModels };
const selector = new TabbedModelSelectorComponent({
models: mergedModels,
currentValue: host.state.appState.model,
selectedValue: Object.keys(mergedModels).find((a) => a.startsWith(`${providerId}/`)),
currentThinking: host.state.appState.thinking,
colors: host.state.theme.colors,
initialTabId: providerId,
onSelect: ({ alias, thinking }) => {
host.restoreEditor();
void setDefaultModel(host, alias, thinking);
},
onCancel: () => {
host.restoreEditor();
},
});
host.mountEditorReplacement(selector);
}
async function setDefaultModel(
host: SlashCommandHost,
alias: string,
thinking: boolean,
): Promise<void> {
await host.harness.setConfig({
defaultModel: alias,
defaultThinking: thinking,
});
await host.authFlow.refreshConfigAfterLogin();
host.track('model_switch', { model: alias });
host.showStatus(`Default model set to ${alias} with thinking ${thinking ? 'on' : 'off'}.`);
}
async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise<boolean> {
const value = await promptCustomRegistryImport(host);
if (value === undefined) return false;
const source: CustomRegistrySource = {
kind: 'apiJson',
url: value.url,
apiKey: value.apiKey,
};
let entries: Record<string, { id: string; name: string; api: string; type: string; models: Record<string, unknown> }>;
try {
entries = await fetchCustomRegistry(source);
} catch (err) {
host.showError(`Failed to import registry: ${formatErrorMessage(err)}`);
return false;
}
const addedProviderIds: string[] = [];
try {
let config = await host.harness.getConfig();
for (const entry of Object.values(entries)) {
if (config.providers[entry.id] !== undefined) {
config = await host.harness.removeProvider(entry.id);
}
applyCustomRegistryProvider(
config as unknown as Parameters<typeof applyCustomRegistryProvider>[0],
entry as Parameters<typeof applyCustomRegistryProvider>[1],
source,
);
addedProviderIds.push(entry.id);
}
await host.harness.setConfig({
providers: config.providers,
models: config.models,
});
await host.authFlow.refreshConfigAfterLogin();
} catch (err) {
host.showError(`Failed to apply registry: ${formatErrorMessage(err)}`);
return false;
}
const count = addedProviderIds.length;
if (count === 0) {
host.showStatus('Registry contained no providers.');
return false;
}
host.showStatus(
count === 1
? 'Imported 1 provider from registry.'
: `Imported ${String(count)} providers from registry.`,
host.state.theme.colors.success,
);
// Offer the model selector so the user can pick a default, just like the
// catalog (known-provider) flow.
const stateModels = await host.harness.getConfig().then((c) => c.models ?? {});
const firstNewAlias = Object.keys(stateModels).find((a) =>
addedProviderIds.some((pid) => a.startsWith(`${pid}/`)),
);
const firstNewProvider = firstNewAlias
? stateModels[firstNewAlias]?.provider
: addedProviderIds[0];
const selector = new TabbedModelSelectorComponent({
models: stateModels,
currentValue: host.state.appState.model,
selectedValue: firstNewAlias,
currentThinking: host.state.appState.thinking,
colors: host.state.theme.colors,
initialTabId: firstNewProvider,
onSelect: ({ alias, thinking }) => {
host.restoreEditor();
void setDefaultModel(host, alias, thinking);
},
onCancel: () => {
host.restoreEditor();
},
});
host.mountEditorReplacement(selector);
return true;
}
function promptCustomRegistryImport(
host: SlashCommandHost,
): Promise<{ readonly url: string; readonly apiKey: string } | undefined> {
return new Promise((resolve) => {
const dialog = new CustomRegistryImportDialogComponent(
(result: CustomRegistryImportResult) => {
host.restoreEditor();
resolve(result.kind === 'ok' ? result.value : undefined);
},
host.state.theme.colors,
);
host.mountEditorReplacement(dialog);
});
}

View file

@ -41,6 +41,14 @@ export const BUILTIN_SLASH_COMMANDS = [
aliases: [],
description: 'Switch LLM model',
priority: 100,
availability: 'always',
},
{
name: 'provider',
aliases: ['providers'],
description: 'Manage AI providers (add / delete / refresh)',
priority: 95,
availability: 'always',
},
{
name: 'help',
@ -153,12 +161,6 @@ 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: 'export-md',
aliases: ['export'],

View file

@ -0,0 +1,226 @@
/**
* CustomRegistryImportDialog blue rounded box that collects a custom
* registry URL and a Bearer token before importing the registry's
* provider entries.
*
* Geometry mirrors `ApiKeyInputDialogComponent` so the chrome stays
* consistent with the API-key login flow. Two fields, switched with
* Tab / Shift-Tab; Enter on the last field submits, Esc cancels.
*/
import {
Container,
Input,
Key,
matchesKey,
truncateToWidth,
visibleWidth,
type Focusable,
} from '@earendil-works/pi-tui';
import chalk from 'chalk';
import type { ColorPalette } from '#/tui/theme/colors';
export interface CustomRegistryImportValue {
readonly url: string;
readonly apiKey: string;
}
export type CustomRegistryImportResult =
| { readonly kind: 'ok'; readonly value: CustomRegistryImportValue }
| { readonly kind: 'cancel' };
const TITLE = 'Import custom provider registry';
const SUBTITLE_DEFAULT = 'Paste an api.json URL and its Bearer token.';
const SUBTITLE_URL_EMPTY = 'Registry URL cannot be empty.';
const SUBTITLE_TOKEN_EMPTY = 'Bearer token cannot be empty.';
const FOOTER = 'Tab to switch · Enter to submit · Esc to cancel';
type FieldId = 'url' | 'token';
function maskInputLine(raw: string): string {
const prefix = '> ';
if (!raw.startsWith(prefix)) return raw;
// Strip trailing padding spaces so they stay as spaces.
let end = raw.length;
while (end > prefix.length && raw[end - 1] === ' ') {
end--;
}
const padding = raw.slice(end);
const content = raw.slice(prefix.length, end);
// Protect ANSI escape sequences (reverse-video cursor, IME marker, etc.)
// while masking every other visible character.
const parts = content.split(/((?:\[[0-9;]*m|_pi:c))/);
const maskedContent = parts
.map((part, index) => {
if (index % 2 === 1) return part; // ANSI sequence
return part.replaceAll(/[^ ]/g, '•');
})
.join('');
return prefix + maskedContent + padding;
}
export class CustomRegistryImportDialogComponent extends Container implements Focusable {
focused = false;
private readonly urlInput = new Input();
private readonly tokenInput = new Input();
private readonly onDone: (result: CustomRegistryImportResult) => void;
private readonly colors: ColorPalette;
private activeField: FieldId = 'url';
private done = false;
private hint: 'none' | 'url-empty' | 'token-empty' = 'none';
constructor(
onDone: (result: CustomRegistryImportResult) => void,
colors: ColorPalette,
defaultUrl: string = '',
) {
super();
this.onDone = onDone;
this.colors = colors;
if (defaultUrl.length > 0) this.urlInput.setValue(defaultUrl);
this.urlInput.onSubmit = () => {
this.handleEnter();
};
this.tokenInput.onSubmit = () => {
this.handleEnter();
};
}
handleInput(data: string): void {
if (this.done) return;
if (
matchesKey(data, Key.escape) ||
matchesKey(data, Key.ctrl('c')) ||
matchesKey(data, Key.ctrl('d'))
) {
this.cancel();
return;
}
if (matchesKey(data, Key.tab) || matchesKey(data, Key.shift('tab'))) {
this.toggleField();
return;
}
if (this.hint !== 'none') {
this.hint = 'none';
}
if (this.activeField === 'url') {
this.urlInput.handleInput(data);
} else {
this.tokenInput.handleInput(data);
}
}
override invalidate(): void {
super.invalidate();
this.urlInput.invalidate();
this.tokenInput.invalidate();
}
override render(width: number): string[] {
const dialogActive = this.focused && !this.done;
this.urlInput.focused = dialogActive && this.activeField === 'url';
this.tokenInput.focused = dialogActive && this.activeField === 'token';
const safeWidth = Math.max(36, width);
const innerWidth = Math.max(10, safeWidth - 4);
const pad = ' ';
const border = (s: string): string => chalk.hex(this.colors.primary)(s);
const titleStyled = chalk.bold.hex(this.colors.textStrong)(TITLE);
const subtitleText =
this.hint === 'url-empty'
? SUBTITLE_URL_EMPTY
: this.hint === 'token-empty'
? SUBTITLE_TOKEN_EMPTY
: SUBTITLE_DEFAULT;
const subtitleStyled = chalk.hex(this.colors.textDim)(subtitleText);
const footerStyled = chalk.hex(this.colors.textDim)(FOOTER);
const urlLabelText = 'Registry URL';
const tokenLabelText = 'Bearer token';
const urlLabelStyled =
this.activeField === 'url'
? chalk.bold.hex(this.colors.accent)(urlLabelText)
: chalk.hex(this.colors.textDim)(urlLabelText);
const tokenLabelStyled =
this.activeField === 'token'
? chalk.bold.hex(this.colors.accent)(tokenLabelText)
: chalk.hex(this.colors.textDim)(tokenLabelText);
const titleLine = truncateToWidth(titleStyled, innerWidth, '…');
const subtitleLine = truncateToWidth(subtitleStyled, innerWidth, '…');
const footerLine = truncateToWidth(footerStyled, innerWidth, '…');
const urlLabelLine = truncateToWidth(urlLabelStyled, innerWidth, '…');
const tokenLabelLine = truncateToWidth(tokenLabelStyled, innerWidth, '…');
const urlInputLine = this.urlInput.render(innerWidth)[0] ?? '> ';
const rawTokenInputLine = this.tokenInput.render(innerWidth)[0] ?? '> ';
const tokenInputLine = maskInputLine(rawTokenInputLine);
const contentLines: string[] = [
titleLine,
'',
subtitleLine,
'',
urlLabelLine,
urlInputLine,
'',
tokenLabelLine,
tokenInputLine,
'',
footerLine,
];
const lines: string[] = [
'',
border('╭' + '─'.repeat(safeWidth - 2) + '╮'),
border('│') + ' '.repeat(safeWidth - 2) + border('│'),
];
for (const content of contentLines) {
const vis = visibleWidth(content);
const rightPad = Math.max(0, innerWidth - vis);
lines.push(border('│') + pad + content + ' '.repeat(rightPad) + border('│'));
}
lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│'));
lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯'));
lines.push('');
return lines;
}
private toggleField(): void {
this.hint = 'none';
this.activeField = this.activeField === 'url' ? 'token' : 'url';
}
private handleEnter(): void {
if (this.done) return;
const urlValue = this.urlInput.getValue().trim();
const tokenValue = this.tokenInput.getValue().trim();
if (urlValue.length === 0) {
this.hint = 'url-empty';
this.activeField = 'url';
return;
}
this.done = true;
this.onDone({ kind: 'ok', value: { url: urlValue, apiKey: tokenValue } });
}
private cancel(): void {
if (this.done) return;
this.done = true;
this.onDone({ kind: 'cancel' });
}
}

View file

@ -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 { printableChar } from '#/tui/utils/printable-key';
import { SearchableList } from '#/tui/utils/searchable-list';
import type { ChoiceOption } from './choice-picker';
@ -56,6 +57,8 @@ export interface ModelSelectorOptions {
readonly searchable?: boolean;
/** Items per page. Lists longer than this paginate (PgUp/PgDn). */
readonly pageSize?: number;
/** When true, the hint line includes a Tab/Shift+Tab provider switch tip. */
readonly providerSwitchHint?: boolean;
readonly onSelect: (selection: ModelSelection) => void;
readonly onCancel: () => void;
}
@ -71,10 +74,6 @@ function createModelChoices(models: Record<string, ModelAlias>): readonly ModelC
function thinkingAvailability(model: ModelAlias): ThinkingAvailability {
const caps = model.capabilities ?? [];
if (caps.includes('always_thinking')) return 'always-on';
// Forcing adaptive thinking implies the model supports thinking, even when the
// alias declares no capabilities — e.g. a custom-named endpoint configured with
// only `adaptive_thinking = true`. Without this it would render as "unsupported"
// and switching to it would force thinking off.
if (caps.includes('thinking') || model.adaptiveThinking === true) return 'toggle';
return 'unsupported';
}
@ -100,7 +99,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
const selectedIdx = choices.findIndex((choice) => choice.alias === selectedValue);
this.list = new SearchableList({
items: choices,
toSearchText: (c) => c.label,
toSearchText: (choice) => choice.label,
pageSize: opts.pageSize,
initialIndex: Math.max(selectedIdx, 0),
searchable: opts.searchable === true,
@ -114,87 +113,101 @@ export class ModelSelectorComponent extends Container implements Focusable {
this.opts.onCancel();
return;
}
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.
const selected = this.selectedChoice();
if (selected !== undefined && thinkingAvailability(selected.model) === 'toggle') {
if (matchesKey(data, Key.left)) {
this.thinkingDraft = true;
return;
}
if (matchesKey(data, Key.right)) {
this.thinkingDraft = false;
const ch = printableChar(data);
if (ch === '/') {
this.thinkingDraft = !this.thinkingDraft;
return;
}
}
if (this.list.handleKey(data)) {
// Consumed by SearchableList (↑/↓/PgUp/PgDn/typing/Backspace).
return;
}
if (matchesKey(data, Key.left)) {
this.list.pageUp();
return;
}
if (matchesKey(data, Key.right)) {
this.list.pageDown();
return;
}
if (matchesKey(data, Key.enter)) {
if (selected === undefined) return;
this.opts.onSelect({
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)') : '';
view.query.length === 0 ? chalk.hex(colors.textMuted)(' (type to search)') : '';
const hintParts: string[] = [];
if (this.opts.providerSwitchHint) {
hintParts.push('Tab/Shift+Tab provider');
}
hintParts.push('↑↓ model', '←→ page', '/ thinking', 'Enter apply', 'Esc cancel');
const lines: string[] = [
chalk.hex(colors.primary)('─'.repeat(width)),
chalk.hex(colors.primary).bold(' Select a model') + titleSuffix,
chalk.hex(colors.textMuted)(' ' + hintParts.join(' · ')),
'',
];
if (searchable && view.query.length > 0) {
if (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('');
if (choices.length === 0) {
if (view.items.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);
let line = chalk.hex(isSelected ? colors.primary : colors.textDim)(` ${pointer} `);
line += labelStyle(choice.label);
if (isCurrent) {
line += ' ' + chalk.hex(colors.success)('← current');
} else {
for (let i = view.page.start; i < view.page.end; i++) {
const choice = view.items[i];
if (choice === undefined) continue;
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);
let line = chalk.hex(isSelected ? colors.primary : colors.textDim)(` ${pointer} `);
line += labelStyle(choice.label);
if (isCurrent) {
line += ' ' + chalk.hex(colors.success)('← current');
}
lines.push(line);
}
lines.push(line);
}
lines.push('');
lines.push(chalk.hex(colors.textMuted)(' Thinking'));
const selected = choices[view.selectedIndex];
if (selected !== undefined) {
lines.push(this.renderThinkingControl(selected.model));
}
lines.push('');
if (view.page.pageCount > 1) {
lines.push('');
lines.push(
chalk.hex(colors.textMuted)(
` Page ${String(view.page.page + 1)}/${String(view.page.pageCount)}`,
),
);
}
lines.push('');
lines.push(chalk.hex(colors.textMuted)(' Thinking (/ to toggle)'));
const selected = this.selectedChoice();
if (selected !== undefined) {
lines.push(this.renderThinkingControl(selected.model));
}
lines.push('');
lines.push(chalk.hex(colors.primary)('─'.repeat(width)));
return lines.map((line) => truncateToWidth(line, width));
}
private selectedChoice(): ModelChoice | undefined {
return this.list.selected();
}
private renderThinkingControl(model: ModelAlias): string {
const { colors } = this.opts;
const segment = (label: string, active: boolean): string =>

View file

@ -0,0 +1,460 @@
/**
* ProviderManagerComponent pure-view CRUD UI for the `/provider` command.
*
* Single-column layout showing one row per "platform / source":
* - each Open Platform login (1 source = 1 provider)
* - each Custom Registry connection grouping by `{url, apiKey}`
* (1 source = N providers from the same api.json fetch)
* - any other configured provider (1 source = 1 provider)
* - a synthetic final `[ Add New Platform ]` action row
* Kimi Code OAuth (`DEFAULT_OAUTH_PROVIDER_NAME`) is intentionally hidden
* that account is managed through `/login` / `/logout`, not here.
*
* Keyboard:
* - / move highlight
* - / page up / down
* - Enter on `[ Add New Platform ]` `onAdd()`
* - d (lowercase) delete with inline `[y/N]` confirmation
* on a source row `onDeleteSource(providerIds)`
* on `[ Add New Platform ]` ignored
* - Esc `onClose()` (outside the confirm substate)
*
* The `[y/N]` confirmation is a transient substate handled in-component:
* while armed, only `y` / `Y` / `n` / `N` / `Esc` are honored and the
* prompt replaces the footer hint.
*
* The component is pure-view: every CRUD side effect is dispatched back
* through callbacks. The host (`KimiTui`) is responsible for performing
* the harness / config mutations and then pushing a fresh snapshot via
* `setOptions`.
*/
import type { ProviderConfig } from '@moonshot-ai/kimi-code-sdk';
import {
getOpenPlatformById,
isOpenPlatformId,
type CustomRegistrySource,
} from '@moonshot-ai/kimi-code-oauth';
import {
Container,
Key,
matchesKey,
truncateToWidth,
visibleWidth,
type Focusable,
} from '@earendil-works/pi-tui';
import chalk from 'chalk';
import { DEFAULT_OAUTH_PROVIDER_NAME } from '#/constant/app';
import type { ColorPalette } from '#/tui/theme/colors';
import { printableChar } from '#/tui/utils/printable-key';
import { pageView, type PageView } from '#/tui/utils/paging';
interface ConfirmState {
readonly label: string;
readonly providerIds: readonly string[];
}
export interface ProviderManagerOptions {
/** All currently configured providers (`config.providers`). */
readonly providers: Record<string, ProviderConfig>;
/** Provider id of the currently active model. */
readonly activeProviderId?: string;
readonly colors: ColorPalette;
readonly onAdd: () => void;
/** Delete all providers under a source (Open Platform / custom-registry
* fetch / standalone). Passed the full provider-id list so the host
* doesn't have to re-derive the source grouping. */
readonly onDeleteSource: (providerIds: readonly string[]) => void;
readonly onClose: () => void;
}
/** Real (non-synthetic) source row. */
interface SourceRow {
readonly kind: 'source';
readonly id: string;
readonly label: string;
readonly providerIds: readonly string[];
/** True when one of `providerIds` is the active provider. */
readonly hasActive: boolean;
/** Optional base URL extracted from the provider config. */
readonly baseUrl?: string;
}
/** Synthetic `[ Add New Platform ]` action row pinned to the bottom. */
interface AddRow {
readonly kind: 'add';
readonly id: '__add__';
readonly label: string;
}
type Row = SourceRow | AddRow;
const ADD_ROW_LABEL = '[ Add New Platform ]';
const PAGE_SIZE = 8;
const HEADER_HINT = '↑↓ navigate · ←→ page · d delete · Esc close';
// Narrows a `ProviderConfig` blob to a `CustomRegistrySource` payload.
// Mirrors `readCustomRegistrySource` in `kimi-tui.ts`. We can't import
// that helper because it lives in the host and would create a cyclic
// dependency on the component's container; duplicating ~15 lines is cheap.
function readCustomRegistrySource(provider: unknown): CustomRegistrySource | undefined {
if (typeof provider !== 'object' || provider === null) return undefined;
const source = (provider as { readonly source?: unknown }).source;
if (typeof source !== 'object' || source === null) return undefined;
const candidate = source as {
readonly kind?: unknown;
readonly url?: unknown;
readonly apiKey?: unknown;
};
if (candidate.kind !== 'apiJson') return undefined;
if (typeof candidate.url !== 'string' || candidate.url.length === 0) return undefined;
if (typeof candidate.apiKey !== 'string') return undefined;
return { kind: 'apiJson', url: candidate.url, apiKey: candidate.apiKey };
}
/**
* Pretty-print a URL for the source-row label. Strips the scheme and
* truncates obvious api.json suffixes so the row stays narrow. Falls
* back to the raw URL if parsing fails.
*/
function sourceUrlLabel(url: string): string {
try {
const parsed = new URL(url);
return parsed.host + parsed.pathname.replace(/\/+$/, '');
} catch {
return url;
}
}
/**
* Group providers into source rows + append the synthetic add-row.
* The grouping rules:
* - `DEFAULT_OAUTH_PROVIDER_NAME` skipped (managed via /logout).
* - Open Platform id (`isOpenPlatformId(id)`) 1 source per provider,
* label = `OpenPlatformDefinition.name`.
* - `cfg.source.kind === 'apiJson'` one source per `{url, apiKey}`
* pair, label = hostname + pathname.
* - Anything else 1 source per provider, label = provider id.
*/
function buildRows(opts: ProviderManagerOptions): readonly Row[] {
const sources: SourceRow[] = [];
// Map from `${url}${apiKey}` → index into `sources`, so we can
// append further providers into the same group.
const customRegistryIndex = new Map<string, number>();
for (const [id, cfg] of Object.entries(opts.providers)) {
if (id === DEFAULT_OAUTH_PROVIDER_NAME) continue;
const isActive = id === opts.activeProviderId;
if (isOpenPlatformId(id)) {
const platform = getOpenPlatformById(id);
sources.push({
kind: 'source',
id: `open:${id}`,
label: platform?.name ?? id,
providerIds: [id],
hasActive: isActive,
});
continue;
}
const baseUrl =
typeof cfg === 'object' && cfg !== null && 'baseUrl' in cfg && typeof cfg.baseUrl === 'string'
? cfg.baseUrl
: undefined;
const customSource = readCustomRegistrySource(cfg);
if (customSource !== undefined) {
const key = `${customSource.url}${customSource.apiKey}`;
const existingIdx = customRegistryIndex.get(key);
if (existingIdx !== undefined) {
const existing = sources[existingIdx];
if (existing !== undefined && existing.kind === 'source') {
sources[existingIdx] = {
kind: 'source',
id: existing.id,
label: existing.label,
providerIds: [...existing.providerIds, id],
hasActive: existing.hasActive || isActive,
baseUrl: existing.baseUrl,
};
}
continue;
}
customRegistryIndex.set(key, sources.length);
sources.push({
kind: 'source',
id: `custom:${key}`,
label: sourceUrlLabel(customSource.url),
providerIds: [id],
hasActive: isActive,
baseUrl,
});
continue;
}
sources.push({
kind: 'source',
id: `provider:${id}`,
label: id,
providerIds: [id],
hasActive: isActive,
baseUrl,
});
}
return [...sources, { kind: 'add', id: '__add__', label: ADD_ROW_LABEL }];
}
export class ProviderManagerComponent extends Container implements Focusable {
focused = false;
private opts: ProviderManagerOptions;
private rows: readonly Row[];
private selectedIndex: number;
private confirm: ConfirmState | undefined;
constructor(opts: ProviderManagerOptions) {
super();
this.opts = opts;
this.rows = buildRows(opts);
const activeIdx = opts.activeProviderId
? this.rows.findIndex(
(row) => row.kind === 'source' && row.providerIds.includes(opts.activeProviderId ?? ''),
)
: -1;
this.selectedIndex = activeIdx >= 0 ? activeIdx : 0;
this.confirm = undefined;
}
/**
* Replace the props the component renders against. Existing selection
* is preserved when possible (by id or first provider id) so deletions
* don't visually jump. Any in-flight `[y/N]` substate is cleared because
* the underlying target may have changed.
*/
setOptions(next: ProviderManagerOptions): void {
const previousSelected = this.rows[this.selectedIndex];
const previousSelectedId = previousSelected?.id;
const previousFirstProviderId =
previousSelected?.kind === 'source' ? previousSelected.providerIds[0] : undefined;
this.opts = next;
this.rows = buildRows(next);
this.confirm = undefined;
let newIdx = -1;
if (previousSelectedId !== undefined) {
newIdx = this.rows.findIndex((row) => row.id === previousSelectedId);
}
if (newIdx < 0 && previousFirstProviderId !== undefined) {
newIdx = this.rows.findIndex(
(row) => row.kind === 'source' && row.providerIds.includes(previousFirstProviderId),
);
}
if (newIdx < 0) {
newIdx = Math.min(this.selectedIndex, Math.max(0, this.rows.length - 1));
}
this.selectedIndex = newIdx;
this.invalidate();
}
private page(): PageView {
return pageView(this.rows.length, this.selectedIndex, PAGE_SIZE);
}
handleInput(data: string): void {
if (this.confirm !== undefined) {
this.handleConfirmInput(data);
return;
}
if (matchesKey(data, Key.escape)) {
this.opts.onClose();
return;
}
if (matchesKey(data, Key.up)) {
if (this.rows.length === 0) return;
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.invalidate();
return;
}
if (matchesKey(data, Key.down)) {
if (this.rows.length === 0) return;
this.selectedIndex = Math.min(this.rows.length - 1, this.selectedIndex + 1);
this.invalidate();
return;
}
if (matchesKey(data, Key.left) || matchesKey(data, Key.pageUp)) {
if (this.rows.length === 0) return;
this.selectedIndex = Math.max(0, this.selectedIndex - PAGE_SIZE);
this.invalidate();
return;
}
if (matchesKey(data, Key.right) || matchesKey(data, Key.pageDown)) {
if (this.rows.length === 0) return;
this.selectedIndex = Math.min(this.rows.length - 1, this.selectedIndex + PAGE_SIZE);
this.invalidate();
return;
}
if (matchesKey(data, Key.enter)) {
const selected = this.rows[this.selectedIndex];
if (selected?.kind === 'add') {
this.opts.onAdd();
}
return;
}
const k = printableChar(data);
if (k === 'd') {
this.armDeleteConfirm();
}
}
private armDeleteConfirm(): void {
const selected = this.rows[this.selectedIndex];
if (selected === undefined || selected.kind === 'add') return;
const ids = selected.providerIds;
const prompt =
ids.length === 1
? `Delete platform "${selected.label}"?`
: `Delete platform "${selected.label}" and all ${String(ids.length)} providers?`;
this.confirm = {
label: prompt,
providerIds: ids,
};
this.invalidate();
}
private handleConfirmInput(data: string): void {
const k = printableChar(data);
if (matchesKey(data, Key.escape) || k === 'n' || k === 'N') {
this.confirm = undefined;
this.invalidate();
return;
}
if (k === 'y' || k === 'Y') {
const confirm = this.confirm;
this.confirm = undefined;
this.invalidate();
if (confirm === undefined) return;
this.opts.onDeleteSource(confirm.providerIds);
return;
}
// Any other key while in the confirm substate is ignored.
}
override render(width: number): string[] {
const { colors } = this.opts;
const lines: string[] = [];
const border = chalk.hex(colors.primary)('─'.repeat(width));
lines.push(border);
lines.push(renderHeader(width, colors));
lines.push(border);
lines.push('');
lines.push(renderColumnHeader(width, colors));
if (this.rows.length === 0) {
lines.push(chalk.hex(colors.textMuted)(' No providers configured.'));
} else {
const view = this.page();
for (let i = view.start; i < view.end; i++) {
const row = this.rows[i];
if (row === undefined) continue;
for (const line of renderRow(row, { isSelected: i === this.selectedIndex, width, colors })) {
lines.push(line);
}
}
}
lines.push('');
if (this.confirm !== undefined) {
lines.push(this.renderConfirmLine(width));
} else {
const view = this.page();
if (view.pageCount > 1) {
lines.push(
chalk.hex(colors.textMuted)(
` Page ${String(view.page + 1)}/${String(view.pageCount)}`,
),
);
}
lines.push(renderFooterHint(width, colors));
}
lines.push(border);
return lines.map((line) => truncateToWidth(line, width));
}
private renderConfirmLine(width: number): string {
const { colors } = this.opts;
const confirm = this.confirm;
const prompt = confirm?.label ?? '';
const styled = chalk.hex(colors.warning).bold(` ${prompt} [y/N]`);
return truncateToWidth(styled, width, '…');
}
}
function renderHeader(width: number, colors: ColorPalette): string {
const title = chalk.hex(colors.primary).bold(' Providers');
return truncateToWidth(title, width, '…');
}
function renderColumnHeader(width: number, colors: ColorPalette): string {
const muted = chalk.hex(colors.textMuted);
const padded = padCell('', Math.max(0, width - 2));
return ` ${muted(padded)}`;
}
function renderFooterHint(width: number, colors: ColorPalette): string {
const hint = chalk.hex(colors.textMuted)(' ' + HEADER_HINT);
return truncateToWidth(hint, width, '…');
}
function renderRow(
row: Row,
ctx: { isSelected: boolean; width: number; colors: ColorPalette },
): string[] {
const { isSelected, width, colors } = ctx;
const pointer = isSelected ? '' : ' ';
const pointerStyle = isSelected ? chalk.hex(colors.primary) : chalk.hex(colors.textDim);
const indicatorStyle = chalk.hex(colors.success);
const labelStyle =
row.kind === 'add'
? chalk.hex(colors.textMuted)
: isSelected
? chalk.hex(colors.primary).bold
: chalk.hex(colors.text);
const indicatorRendered =
row.kind === 'source' && row.hasActive ? indicatorStyle('● ') : ' ';
// Reserve 2 leading spaces + 2 for pointer + 2 for indicator.
const labelWidth = Math.max(0, width - 6);
const labelText = truncateToWidth(row.label, labelWidth, '…');
const styledLabel = labelStyle(labelText);
const labelPadded = styledLabel + ' '.repeat(Math.max(0, labelWidth - visibleWidth(labelText)));
const lines: string[] = [` ${pointerStyle(`${pointer} `)}${indicatorRendered}${labelPadded}`];
if (row.kind === 'source' && row.baseUrl !== undefined && row.baseUrl.length > 0) {
const urlText = truncateToWidth(row.baseUrl, Math.max(0, width - 6), '…');
lines.push(chalk.hex(colors.textMuted)(` ${urlText}`));
}
return lines;
}
function padCell(text: string, width: number): string {
const w = visibleWidth(text);
if (w >= width) return truncateToWidth(text, width, '…');
return text + ' '.repeat(width - w);
}

View file

@ -0,0 +1,265 @@
/**
* TabbedModelSelectorComponent a thin wrapper around ModelSelectorComponent
* that splits the model list into per-provider tabs.
*
* Tabs are derived from the `models` passed at construction time:
* ['all', ...uniqueProviderIds] (insertion order, deduplicated)
*
* Each tab owns its own inner ModelSelectorComponent built from the filtered
* subset of models. Up/Down/Enter/Esc/Left/Right are forwarded to the active
* inner selector; Tab / Shift-Tab cycle between tabs.
*
* Note: the flat ModelSelectorComponent is intentionally left untouched the
* OpenPlatform login flow (see promptModelSelectionForOpenPlatform) keeps
* using it directly.
*/
import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk';
import {
Container,
Key,
matchesKey,
truncateToWidth,
visibleWidth,
type Focusable,
} from '@earendil-works/pi-tui';
import chalk from 'chalk';
import type { ColorPalette } from '#/tui/theme/colors';
import {
ModelSelectorComponent,
providerDisplayName,
type ModelSelection,
type ModelSelectorOptions,
} from './model-selector';
const ALL_TAB_ID = 'all';
const ALL_TAB_LABEL = 'All';
export interface TabbedModelSelectorOptions {
readonly models: Record<string, ModelAlias>;
readonly currentValue: string;
readonly selectedValue?: string;
readonly currentThinking: boolean;
readonly colors: ColorPalette;
/** When set, the tab for this provider id is initially active instead of the
* tab derived from `currentValue`. */
readonly initialTabId?: string;
readonly onSelect: (selection: ModelSelection) => void;
readonly onCancel: () => void;
}
interface ModelTab {
readonly id: string;
readonly label: string;
readonly selector: ModelSelectorComponent;
}
export class TabbedModelSelectorComponent extends Container implements Focusable {
focused = false;
private readonly opts: TabbedModelSelectorOptions;
private readonly tabs: readonly ModelTab[];
private activeIndex: number;
constructor(opts: TabbedModelSelectorOptions) {
super();
this.opts = opts;
this.tabs = buildTabs(opts);
const preferredProvider =
opts.initialTabId ??
opts.models[opts.selectedValue ?? '']?.provider ??
opts.models[opts.currentValue]?.provider;
const initialTabIdx = preferredProvider
? this.tabs.findIndex((tab) => tab.id === preferredProvider)
: -1;
this.activeIndex = initialTabIdx >= 0 ? initialTabIdx : 0;
this.syncFocusToActive();
}
handleInput(data: string): void {
if (this.tabs.length > 1) {
if (matchesKey(data, Key.tab)) {
this.activeIndex = (this.activeIndex + 1) % this.tabs.length;
this.syncFocusToActive();
return;
}
if (matchesKey(data, Key.shift('tab'))) {
this.activeIndex = (this.activeIndex - 1 + this.tabs.length) % this.tabs.length;
this.syncFocusToActive();
return;
}
}
this.tabs[this.activeIndex]?.selector.handleInput(data);
}
override render(width: number): string[] {
const active = this.tabs[this.activeIndex];
if (active === undefined) return [];
const inner = active.selector.render(width);
if (this.tabs.length <= 1) {
return inner.map((line) => truncateToWidth(line, width));
}
// Inject the tab strip just after the inner selector's top divider so
// it sits inside the frame rather than above it.
const stripLine = this.renderTabStrip(width);
const out: string[] = [];
out.push(inner[0] ?? '');
out.push(stripLine);
out.push(chalk.hex(this.opts.colors.primary)('─'.repeat(width)));
for (let i = 1; i < inner.length; i++) out.push(inner[i]!);
return out.map((line) => truncateToWidth(line, width));
}
override invalidate(): void {
super.invalidate();
for (const tab of this.tabs) {
tab.selector.invalidate();
}
}
private syncFocusToActive(): void {
for (let i = 0; i < this.tabs.length; i++) {
const tab = this.tabs[i]!;
tab.selector.focused = this.focused && i === this.activeIndex;
}
}
private renderTabStrip(width: number): string {
const { colors } = this.opts;
const segments: string[] = [];
for (let i = 0; i < this.tabs.length; i++) {
const tab = this.tabs[i]!;
const isActive = i === this.activeIndex;
const label = ` ${tab.label} `;
const styled = isActive
? chalk.hex(colors.primary).bold(`[${label}]`)
: chalk.hex(colors.textMuted)(` ${label} `);
segments.push(styled);
}
// If everything fits with a leading space, show all.
const totalSegmentWidth = segments.reduce((sum, s) => sum + visibleWidth(s), 0);
if (1 + totalSegmentWidth <= width) {
const hint = chalk.hex(colors.textMuted)('Tab / Shift+Tab provider');
let strip = ' ' + segments.join('');
const available = width - visibleWidth(strip) - 1;
if (available >= visibleWidth(hint) + 1) {
const pad = ' '.repeat(available - visibleWidth(hint));
strip += pad + hint;
}
return strip;
}
// Scrolling needed. Find the widest window that contains activeIndex.
const segmentWidths = segments.map((s) => visibleWidth(s));
let start = this.activeIndex;
let end = this.activeIndex + 1;
let contentWidth = segmentWidths[this.activeIndex]!;
const fits = (s: number, e: number, cw: number): boolean => {
const needLeft = s > 0;
const needRight = e < segments.length;
const frameWidth = (needLeft ? 2 : 1) + (needRight ? 2 : 0);
return cw + frameWidth <= width;
};
while (true) {
const leftW = start > 0 ? segmentWidths[start - 1]! : Infinity;
const rightW = end < segments.length ? segmentWidths[end]! : Infinity;
if (leftW === Infinity && rightW === Infinity) break;
if (leftW <= rightW) {
if (fits(start - 1, end, contentWidth + leftW)) {
contentWidth += leftW;
start--;
} else if (fits(start, end + 1, contentWidth + rightW)) {
contentWidth += rightW;
end++;
} else {
break;
}
} else {
if (fits(start, end + 1, contentWidth + rightW)) {
contentWidth += rightW;
end++;
} else if (fits(start - 1, end, contentWidth + leftW)) {
contentWidth += leftW;
start--;
} else {
break;
}
}
}
const hasLeft = start > 0;
const hasRight = end < segments.length;
let strip = hasLeft ? chalk.hex(colors.textMuted)('< ') : ' ';
strip += segments.slice(start, end).join('');
if (hasRight) {
strip += chalk.hex(colors.textMuted)(' >');
}
const hint = chalk.hex(colors.textMuted)('Tab / Shift+Tab provider');
const available = width - visibleWidth(strip) - 1;
if (available >= visibleWidth(hint) + 1) {
const pad = ' '.repeat(available - visibleWidth(hint));
strip += pad + hint;
}
return strip;
}
}
function buildTabs(opts: TabbedModelSelectorOptions): readonly ModelTab[] {
const entries = Object.entries(opts.models);
const providerIds: string[] = [];
const seen = new Set<string>();
for (const [, model] of entries) {
const provider = model.provider;
if (!seen.has(provider)) {
seen.add(provider);
providerIds.push(provider);
}
}
const tabs: ModelTab[] = [];
tabs.push({
id: ALL_TAB_ID,
label: ALL_TAB_LABEL,
selector: makeSelector(opts, opts.models),
});
for (const providerId of providerIds) {
const subset: Record<string, ModelAlias> = {};
for (const [alias, model] of entries) {
if (model.provider === providerId) subset[alias] = model;
}
tabs.push({
id: providerId,
label: providerDisplayName(providerId),
selector: makeSelector(opts, subset),
});
}
return tabs;
}
function makeSelector(
opts: TabbedModelSelectorOptions,
subset: Record<string, ModelAlias>,
): ModelSelectorComponent {
const candidate = opts.selectedValue ?? opts.currentValue;
const selectedValue = subset[candidate] !== undefined ? candidate : undefined;
const inner: ModelSelectorOptions = {
models: subset,
currentValue: opts.currentValue,
...(selectedValue !== undefined ? { selectedValue } : {}),
currentThinking: opts.currentThinking,
colors: opts.colors,
searchable: true,
providerSwitchHint: true,
onSelect: opts.onSelect,
onCancel: opts.onCancel,
};
return new ModelSelectorComponent(inner);
}

View file

@ -2,6 +2,7 @@ import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk';
import type { SkillListSession } from '../commands';
import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui';
import { refreshAllProviderModels } from '../utils/refresh-providers';
import type { SessionEventHandler } from './session-event-handler';
import type { AppState, KimiTUIOptions } from '../types';
import type { TUIState } from '../tui-state';
@ -134,4 +135,36 @@ export class AuthFlowController {
contextTokens: 0,
});
}
/**
* Re-fetch model lists from every provider whose upstream supports it
* (managed OAuth, open platforms, custom registries) and update local
* config. Runs best-effort: individual provider failures are collected
* and returned instead of thrown.
*/
async refreshProviderModels(): Promise<{
readonly changed: ReadonlyArray<{
readonly providerId: string;
readonly providerName: string;
readonly added: number;
readonly removed: number;
}>;
readonly unchanged: readonly string[];
readonly failed: ReadonlyArray<{ readonly provider: string; readonly reason: string }>;
}> {
const { host } = this;
const result = await refreshAllProviderModels({
getConfig: () => host.harness.getConfig({ reload: true }),
removeProvider: (id) => host.harness.removeProvider(id),
setConfig: (patch) => host.harness.setConfig(patch),
resolveOAuthToken: async (providerName, oauthRef) => {
const tokenProvider = host.harness.auth.resolveOAuthTokenProvider(providerName, oauthRef);
return tokenProvider.getAccessToken();
},
});
if (result.changed.length > 0) {
await this.refreshAvailableModels();
}
return result;
}
}

View file

@ -401,6 +401,26 @@ export class KimiTUI {
this.refreshTerminalThemeTracking();
}
private async refreshProviderModelsInBackground(): Promise<void> {
try {
const result = await this.authFlow.refreshProviderModels();
for (const c of result.changed) {
const parts: string[] = [c.providerName];
if (c.added > 0) parts.push(`+${String(c.added)} model${c.added > 1 ? 's' : ''}`);
if (c.removed > 0) parts.push(`-${String(c.removed)} model${c.removed > 1 ? 's' : ''}`);
this.showStatus(parts.join(' · ') + '.');
}
for (const f of result.failed) {
this.showStatus(
`Skipped refreshing ${f.provider}: ${f.reason}`,
this.state.theme.colors.warning,
);
}
} catch {
// Best-effort: startup must not crash on background refresh failures.
}
}
private async finishStartup(shouldReplayHistory: boolean): Promise<void> {
if (this.startupNotice !== undefined) {
this.showStatus(this.startupNotice);
@ -436,6 +456,7 @@ export class KimiTUI {
private async init(): Promise<boolean> {
await this.authFlow.refreshAvailableModels();
void this.refreshProviderModelsInBackground();
const { startup } = this.options;
const { workDir } = this.state.appState;

View file

@ -0,0 +1,291 @@
import {
KIMI_CODE_PROVIDER_NAME,
applyManagedKimiCodeConfig,
applyOpenPlatformConfig,
applyCustomRegistryProvider,
clearManagedKimiCodeConfig,
fetchCustomRegistry,
fetchManagedKimiCodeModels,
fetchOpenPlatformModels,
filterModelsByPrefix,
getOpenPlatformById,
isOpenPlatformId,
removeCustomRegistryProvider,
removeOpenPlatformConfig,
type CustomRegistrySource,
type ManagedKimiConfigShape,
} from '@moonshot-ai/kimi-code-oauth';
import type { KimiConfig, KimiConfigPatch, OAuthRef, ProviderConfig } from '@moonshot-ai/kimi-code-sdk';
export interface RefreshProviderHost {
getConfig(): Promise<KimiConfig>;
removeProvider(providerId: string): Promise<KimiConfig>;
setConfig(patch: KimiConfigPatch): Promise<KimiConfig>;
resolveOAuthToken(providerName: string, oauthRef?: OAuthRef): Promise<string>;
}
export interface ProviderChange {
readonly providerId: string;
/** User-facing name when available. */
readonly providerName: string;
readonly added: number;
readonly removed: number;
}
export interface RefreshResult {
/** Providers whose model list actually changed. */
readonly changed: readonly ProviderChange[];
/** Providers whose model list stayed identical after refresh. */
readonly unchanged: readonly string[];
readonly failed: ReadonlyArray<{ readonly provider: string; readonly reason: string }>;
}
function readCustomRegistrySource(provider: ProviderConfig): CustomRegistrySource | undefined {
const source = provider.source;
if (typeof source !== 'object' || source === null) return undefined;
const candidate = source as Record<string, unknown>;
if (candidate['kind'] !== 'apiJson') return undefined;
const url = candidate['url'];
const apiKey = candidate['apiKey'];
if (typeof url !== 'string' || url.length === 0) return undefined;
if (typeof apiKey !== 'string') return undefined;
return { kind: 'apiJson', url, apiKey };
}
function asManaged(config: KimiConfig): ManagedKimiConfigShape {
return config as unknown as ManagedKimiConfigShape;
}
function collectModelIdsForProvider(config: KimiConfig, providerId: string): Set<string> {
const ids = new Set<string>();
for (const alias of Object.values(config.models ?? {})) {
if (alias.provider === providerId && alias.model.length > 0) {
ids.add(alias.model);
}
}
return ids;
}
function setsEqual<T>(a: Set<T>, b: Set<T>): boolean {
if (a.size !== b.size) return false;
for (const item of a) {
if (!b.has(item)) return false;
}
return true;
}
function computeChanges(oldIds: Set<string>, newIds: Set<string>): { added: number; removed: number } {
let added = 0;
for (const id of newIds) {
if (!oldIds.has(id)) added++;
}
let removed = 0;
for (const id of oldIds) {
if (!newIds.has(id)) removed++;
}
return { added, removed };
}
function pickDefaultModel(config: KimiConfig, providerId: string, models: Array<{ id: string }>): string {
const firstModel = models[0];
if (firstModel === undefined) return '';
const existingDefault = config.defaultModel;
if (existingDefault !== undefined) {
const alias = config.models?.[existingDefault];
if (alias !== undefined && alias.provider === providerId) {
const stillAvailable = models.find((m) => m.id === alias.model);
if (stillAvailable !== undefined) {
return stillAvailable.id;
}
}
}
return firstModel.id;
}
export async function refreshAllProviderModels(host: RefreshProviderHost): Promise<RefreshResult> {
const changed: ProviderChange[] = [];
const unchanged: string[] = [];
const failed: Array<{ provider: string; reason: string }> = [];
let config = await host.getConfig();
// -------------------------------------------------------------------------
// 1. Managed Kimi Code (OAuth)
// -------------------------------------------------------------------------
const managedProvider = config.providers[KIMI_CODE_PROVIDER_NAME];
if (
managedProvider !== undefined &&
managedProvider.type === 'kimi' &&
managedProvider.oauth !== undefined
) {
try {
const accessToken = await host.resolveOAuthToken(
KIMI_CODE_PROVIDER_NAME,
managedProvider.oauth,
);
const models = await fetchManagedKimiCodeModels({
accessToken,
baseUrl: managedProvider.baseUrl,
});
if (models.length > 0) {
const beforeIds = collectModelIdsForProvider(config, KIMI_CODE_PROVIDER_NAME);
const newIds = new Set(models.map((m) => m.id));
if (setsEqual(beforeIds, newIds)) {
unchanged.push(KIMI_CODE_PROVIDER_NAME);
} else {
const { added, removed } = computeChanges(beforeIds, newIds);
config = await host.removeProvider(KIMI_CODE_PROVIDER_NAME);
clearManagedKimiCodeConfig(asManaged(config));
applyManagedKimiCodeConfig(asManaged(config), {
models,
baseUrl: managedProvider.baseUrl,
preserveDefaultModel: true,
});
await host.setConfig({
providers: config.providers,
models: config.models,
defaultModel: config.defaultModel,
defaultThinking: config.defaultThinking,
});
changed.push({
providerId: KIMI_CODE_PROVIDER_NAME,
providerName: 'Kimi Code',
added,
removed,
});
}
}
} catch (error) {
failed.push({
provider: KIMI_CODE_PROVIDER_NAME,
reason: error instanceof Error ? error.message : String(error),
});
}
}
// -------------------------------------------------------------------------
// 2. Open Platforms (moonshot-cn, moonshot-ai, …)
// -------------------------------------------------------------------------
const openPlatformIds = Object.keys(config.providers).filter((id) => isOpenPlatformId(id));
for (const providerId of openPlatformIds) {
const platform = getOpenPlatformById(providerId);
if (platform === undefined) continue;
const providerConfig = config.providers[providerId];
if (providerConfig === undefined) continue;
const apiKey = providerConfig.apiKey;
if (typeof apiKey !== 'string' || apiKey.length === 0) continue;
try {
let models = await fetchOpenPlatformModels(platform, apiKey);
models = filterModelsByPrefix(models, platform);
if (models.length === 0) continue;
const beforeIds = collectModelIdsForProvider(config, providerId);
const newIds = new Set(models.map((m) => m.id));
if (setsEqual(beforeIds, newIds)) {
unchanged.push(providerId);
} else {
const { added, removed } = computeChanges(beforeIds, newIds);
const selectedModelId = pickDefaultModel(config, providerId, models);
const selectedModel = models.find((m) => m.id === selectedModelId);
if (selectedModel === undefined) continue;
config = await host.removeProvider(providerId);
removeOpenPlatformConfig(asManaged(config), providerId);
applyOpenPlatformConfig(asManaged(config), {
platform,
models,
selectedModel,
thinking: false,
apiKey,
});
await host.setConfig({
providers: config.providers,
models: config.models,
defaultModel: config.defaultModel,
defaultThinking: config.defaultThinking,
});
changed.push({
providerId,
providerName: platform.name,
added,
removed,
});
}
} catch (error) {
failed.push({
provider: providerId,
reason: error instanceof Error ? error.message : String(error),
});
}
}
// -------------------------------------------------------------------------
// 3. Custom Registry providers (grouped by {url, apiKey})
// -------------------------------------------------------------------------
const customSources = new Map<string, { source: CustomRegistrySource; providerIds: string[] }>();
for (const [providerId, providerConfig] of Object.entries(config.providers)) {
if (providerId === KIMI_CODE_PROVIDER_NAME) continue;
if (isOpenPlatformId(providerId)) continue;
const source = readCustomRegistrySource(providerConfig);
if (source === undefined) continue;
const key = `${source.url}${source.apiKey}`;
const entry = customSources.get(key);
if (entry !== undefined) {
entry.providerIds.push(providerId);
} else {
customSources.set(key, { source, providerIds: [providerId] });
}
}
for (const { source, providerIds } of customSources.values()) {
try {
const entries = await fetchCustomRegistry(source);
let changedAny = false;
for (const providerId of providerIds) {
const entry = entries[providerId];
if (entry === undefined) continue;
const beforeIds = collectModelIdsForProvider(config, providerId);
const newIds = new Set(Object.values(entry.models).map((m) => m.id));
if (setsEqual(beforeIds, newIds)) {
unchanged.push(providerId);
} else {
const { added, removed } = computeChanges(beforeIds, newIds);
config = await host.removeProvider(providerId);
removeCustomRegistryProvider(asManaged(config), providerId);
applyCustomRegistryProvider(asManaged(config), entry, source);
changedAny = true;
changed.push({
providerId,
providerName: entry.name || providerId,
added,
removed,
});
}
}
if (changedAny) {
await host.setConfig({
providers: config.providers,
models: config.models,
});
}
} catch (error) {
for (const providerId of providerIds) {
failed.push({
provider: providerId,
reason: error instanceof Error ? error.message : String(error),
});
}
}
}
return { changed, unchanged, failed };
}

View file

@ -48,11 +48,6 @@ describe('resolveSlashCommandInput', () => {
commandName: 'init',
reason: 'streaming',
});
expect(resolve('/model', { isStreaming: true })).toEqual({
kind: 'blocked',
commandName: 'model',
reason: 'streaming',
});
expect(resolve('/sessions', { isStreaming: true })).toEqual({
kind: 'blocked',
commandName: 'sessions',
@ -66,11 +61,6 @@ describe('resolveSlashCommandInput', () => {
});
it('blocks model and session pickers while compacting', () => {
expect(resolve('/model', { isCompacting: true })).toEqual({
kind: 'blocked',
commandName: 'model',
reason: 'compacting',
});
expect(resolve('/sessions', { isCompacting: true })).toEqual({
kind: 'blocked',
commandName: 'sessions',

View file

@ -1,4 +1,3 @@
import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk';
import { describe, expect, it, vi } from 'vitest';
import { ChoicePickerComponent } from '#/tui/components/dialogs/choice-picker';
@ -74,7 +73,7 @@ describe('ChoicePickerComponent', () => {
});
const modelOutput = model.render(120).map(strip);
expect(modelOutput).toContain(' Kimi K2 (Kimi Code) ← current');
expect(modelOutput).toContain(' Thinking');
expect(modelOutput).toContain(' Thinking (/ to toggle)');
expect(modelOutput).toContain(' [ On ] Off ');
const theme = new ThemeSelectorComponent({
@ -122,7 +121,7 @@ describe('ChoicePickerComponent', () => {
onCancel: vi.fn(),
});
picker.handleInput('\u001B[C');
picker.handleInput('/');
picker.handleInput('\r');
expect(onSelect).toHaveBeenCalledWith({ alias: 'kimi', thinking: false });
@ -155,46 +154,15 @@ describe('ChoicePickerComponent', () => {
});
expect(picker.render(120).map(strip)).toContain(' [ Always on ]');
picker.handleInput('\u001B[C');
picker.handleInput('\r');
expect(onSelect).toHaveBeenLastCalledWith({ alias: 'always', thinking: true });
picker.handleInput('\u001B[B');
expect(picker.render(120).map(strip)).toContain(' [ Off ] unsupported');
picker.handleInput('\u001B[D');
picker.handleInput('\r');
expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: false });
});
it('treats adaptiveThinking models as thinking-capable without a thinking capability', () => {
const onSelect = vi.fn();
const picker = new ModelSelectorComponent({
models: {
okapi: {
provider: 'anthropic',
model: 'coding-model-okapi-0527-vibe',
maxContextSize: 200_000,
adaptiveThinking: true,
},
},
currentValue: 'okapi',
currentThinking: true,
colors: darkColors,
onSelect,
onCancel: vi.fn(),
});
// adaptiveThinking makes the alias togglable (not 'unsupported'): the current
// thinking state is preserved on select instead of being forced off.
picker.handleInput('\r');
expect(onSelect).toHaveBeenLastCalledWith({ alias: 'okapi', thinking: true });
// Right (ESC[C) toggles thinking off, proving it is an interactive toggle.
picker.handleInput('\u001B[C');
picker.handleInput('\r');
expect(onSelect).toHaveBeenLastCalledWith({ alias: 'okapi', thinking: false });
});
it('keeps the thinking draft when moving across models', () => {
const onSelect = vi.fn();
const picker = new ModelSelectorComponent({
@ -222,7 +190,7 @@ describe('ChoicePickerComponent', () => {
});
picker.handleInput('\u001B[B');
picker.handleInput('\u001B[D');
picker.handleInput('/');
picker.handleInput('\u001B[A');
picker.handleInput('\u001B[B');
picker.handleInput('\r');
@ -230,180 +198,3 @@ 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');
});
});

View file

@ -13,6 +13,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel';
import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app';
import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector';
import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-model-selector';
import {
PluginMcpSelectorComponent,
PluginMarketplaceSelectorComponent,
@ -423,7 +424,7 @@ describe('KimiTUI message flow', () => {
const { driver, harness } = await makeDriver();
driver.state.appState.streamingPhase = 'waiting';
for (const command of ['/new', '/model', '/sessions']) {
for (const command of ['/new', '/sessions']) {
harness.track.mockClear();
driver.handleUserInput(command);
@ -1743,18 +1744,18 @@ describe('KimiTUI message flow', () => {
driver.handleUserInput('/model turbo');
const picker = driver.state.editorContainer.children[0];
expect(picker).toBeInstanceOf(ModelSelectorComponent);
const pickerOutput = stripSgr((picker as ModelSelectorComponent).render(120).join('\n'));
expect(picker).toBeInstanceOf(TabbedModelSelectorComponent);
const pickerOutput = stripSgr((picker as TabbedModelSelectorComponent).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'));
(picker as TabbedModelSelectorComponent).handleInput('t');
(picker as TabbedModelSelectorComponent).handleInput('u');
const filteredOutput = stripSgr((picker as TabbedModelSelectorComponent).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');
(picker as TabbedModelSelectorComponent).handleInput('/');
(picker as TabbedModelSelectorComponent).handleInput('\r');
await vi.waitFor(() => {
expect(session.setModel).toHaveBeenCalledWith('turbo');
@ -1791,8 +1792,8 @@ describe('KimiTUI message flow', () => {
driver.handleUserInput('/model k2');
const picker = driver.state.editorContainer.children[0];
expect(picker).toBeInstanceOf(ModelSelectorComponent);
(picker as ModelSelectorComponent).handleInput('\r');
expect(picker).toBeInstanceOf(TabbedModelSelectorComponent);
(picker as TabbedModelSelectorComponent).handleInput('\r');
await vi.waitFor(() => {
expect(setConfig).toHaveBeenCalledWith({

View file

@ -29,18 +29,22 @@ ANTHROPIC_BASE_URL = "https://my-proxy.example.com"
The most common ways to switch providers are: use the `/model` slash command inside the TUI to pick from already-configured models, or edit `config.toml` directly to adjust the `[providers.*]` and `[models.*]` tables. See [Config files](./config-files.md) for the full field reference.
## `/connect` and the model catalog
## `/provider` and provider management
Instead of writing `[providers.*]` and `[models.*]` tables by hand, run the `/connect` slash command inside the TUI to add a provider from a **model catalog**. The catalog lists known providers and models together with their context window, output limit, and capabilities. `/connect` prompts you to pick a provider and a model, asks for an API key, and writes the resulting `[providers.<name>]` and `[models.<alias>]` entries to `config.toml`.
Run the `/provider` slash command inside the TUI to open the **provider manager**, an interactive view of every configured provider grouped by source. From this screen you can add new providers or delete existing ones without editing `config.toml` by hand.
The default catalog is bundled with the CLI, so `/connect` works offline. Two flags change the catalog source:
The manager lists each platform source — open platform logins, custom registry imports, and standalone providers — as a single row. Navigate with ↑/↓ and page with ←/→. Press `d` on a row to delete that source; a `[y/N]` confirmation appears before anything is removed. Press `Enter` on the `[ Add New Platform ]` row to add a provider.
- `/connect --refresh` fetches the latest catalog from [models.dev](https://models.dev/) before showing the picker.
- `/connect --url=<catalog-url>` reads the catalog from a custom URL that follows the same format. Only `http://` and `https://` URLs are accepted.
Adding a provider offers two paths:
`/connect` only configures the provider types listed in the table above. For other provider types, configure them by hand in `config.toml` as described in the per-type sections below.
- **Known third-party provider** — fetches the latest catalog from [models.dev](https://models.dev/), lets you pick a provider and enter its API key, then opens the model selector so you can choose the default model.
- **Custom registry (api.json)** — imports one or more providers from a custom registry URL. Paste the registry address and its Bearer token; the CLI fetches the registry, creates the corresponding `[providers.<name>]` and `[models.<alias>]` entries, and refreshes the available model list automatically.
`/logout` works on `/connect`-configured providers too: it removes the corresponding `[providers.<name>]` entry from `config.toml`.
When you add a provider or switch models, the **tabbed model selector** splits the available models into per-provider tabs. Press `Tab` / `Shift-Tab` to cycle between tabs, then use the usual ↑/↓ and `Enter` to pick the model you want.
::: warning Note
The Kimi Code OAuth provider (the account you sign into with `/login`) is intentionally hidden from `/provider`; manage that account with `/login` and `/logout` instead.
:::
## `kimi`

View file

@ -14,8 +14,8 @@ Some commands are only available in the idle state. Running them while the sessi
| --- | --- | --- | --- |
| `/login` | — | Pick an account or platform and sign in: Kimi Code uses the OAuth device code flow, while Kimi Platform signs in with an API key. | No |
| `/logout` | — | Clear the credentials of the currently selected account (Kimi Code OAuth credentials, or the corresponding open platform provider config). | No |
| `/connect [--refresh] [--url=<catalog-url>]` | — | Configure a provider and model from a model catalog. The default catalog is bundled with the CLI; pass `--refresh` to fetch the latest catalog from models.dev, or `--url` to read it from a custom URL. See [Providers and models — `/connect` and the model catalog](../configuration/providers.md#connect-and-the-model-catalog). | No |
| `/model` | — | Switch the LLM model used by the current session. | No |
| `/provider` | — | Open the interactive provider manager to view, add, and delete configured providers. See [Providers and models — `/provider` and provider management](../configuration/providers.md#provider-and-provider-management). | Yes |
| `/model` | — | Switch the LLM model used by the current session. | Yes |
| `/settings` | `/config` | Open the settings panel inside the TUI. | Yes |
| `/permission` | — | Choose a permission mode. | Yes |
| `/editor` | — | Configure the external editor launched by `Ctrl-G`. | Yes |

View file

@ -29,18 +29,22 @@ ANTHROPIC_BASE_URL = "https://my-proxy.example.com"
切换供应商最常见的方式有两种:在 TUI 里用 `/model` 斜杠命令选择已配置的模型,或者直接编辑 `config.toml` 调整 `[providers.*]``[models.*]` 表。完整字段说明见 [配置文件](./config-files.md)。
## `/connect` 与模型目录
## `/provider` 与供应商管理
除了在 `config.toml` 中手写 `[providers.*]``[models.*]` 表,你也可以在 TUI 中运行 `/connect` 斜杠命令,从 **模型目录**model catalog添加供应商。模型目录记录了已知的供应商和模型以及它们的上下文长度、输出长度和能力。`/connect` 会引导你选择供应商、选择模型、输入 API 密钥,然后把对应的 `[providers.<name>]``[models.<alias>]` 写入 `config.toml`
在 TUI 中运行 `/provider` 斜杠命令可打开 **供应商管理器**,以交互方式查看按来源分组的所有已配置供应商。在这个界面里,你可以直接添加或删除供应商,而无需手动编辑 `config.toml`
CLI 已经内置了默认的模型目录,因此 `/connect` 无需联网即可使用。如果想换用别的来源,可以传入以下参数:
管理器把每种平台来源(开放平台登录、自定义 registry 导入、独立供应商)显示为一行。用 ↑/↓ 移动光标,←/→ 翻页。在某一行的位置按 `d` 可删除该来源;删除前会出现 `[y/N]` 确认提示。在 `[ Add New Platform ]` 行按 `Enter` 即可添加新供应商。
- `/connect --refresh`:在打开选择器之前,从 [models.dev](https://models.dev/) 拉取最新模型目录。
- `/connect --url=<catalog-url>`:从自定义地址读取模型目录(格式需与默认目录一致),只接受 `http://``https://` 的 URL。
添加供应商时提供两条路径:
`/connect` 只能配置上表列出的供应商类型;不在目录范围内的供应商类型,请按下面各小节的说明,在 `config.toml` 中手写配置。
- **Known third-party provider** — 从 [models.dev](https://models.dev/) 拉取最新模型目录,选择供应商并输入 API 密钥,随后打开模型选择器让你挑选默认模型。
- **Custom registry (api.json)** — 从自定义 registry 地址导入一个或多个供应商。粘贴 registry 地址及其 Bearer tokenCLI 会拉取 registry 内容,自动创建对应的 `[providers.<name>]``[models.<alias>]` 条目,并刷新可用模型列表。
对通过 `/connect` 配置的供应商,`/logout` 同样有效:它会从 `config.toml` 中删除对应的 `[providers.<name>]` 配置块。
添加供应商或切换模型时,**标签页式模型选择器** 会把可用模型按供应商拆分为多个标签页。按 `Tab` / `Shift-Tab` 切换标签页,随后用 ↑/↓ 与 `Enter` 选择想要的模型即可。
::: warning 注意
通过 `/login` 登录的 Kimi Code OAuth 供应商(托管账号)在 `/provider` 中会被故意隐藏;该账号请通过 `/login``/logout` 管理。
:::
## `kimi`

View file

@ -14,8 +14,8 @@
| --- | --- | --- | --- |
| `/login` | — | 选择账号或平台并登录Kimi Code 走 OAuth 验证码流程Kimi Platform 通过 API 密钥登录。 | 否 |
| `/logout` | — | 清除当前所选账号的凭据Kimi Code OAuth 凭据,或对应开放平台的供应商配置)。 | 否 |
| `/connect [--refresh] [--url=<catalog-url>]` | — | 从模型目录中选择并配置供应商与模型。CLI 已内置默认目录;传入 `--refresh` 可从 models.dev 拉取最新目录,传入 `--url` 可指向自定义目录地址。详见 [平台与模型 — `/connect` 与模型目录](../configuration/providers.md#connect-与模型目录)。 | 否 |
| `/model` | — | 切换当前会话使用的 LLM 模型。 | |
| `/provider` | — | 打开交互式供应商管理器,查看、添加和删除已配置的供应商。详见 [平台与模型 — `/provider` 与供应商管理](../configuration/providers.md#provider-与供应商管理)。 | 是 |
| `/model` | — | 切换当前会话使用的 LLM 模型。 | |
| `/settings` | `/config` | 打开 TUI 内的设置面板。 | 是 |
| `/permission` | — | 选择权限模式permission mode。 | 是 |
| `/editor` | — | 配置 `Ctrl-G` 调起的外部编辑器。 | 是 |

View file

@ -31,6 +31,7 @@ export const ProviderConfigSchema = z.object({
oauth: OAuthRefSchema.optional(),
env: StringRecordSchema.optional(),
customHeaders: StringRecordSchema.optional(),
source: z.record(z.string(), z.unknown()).optional(),
});
export type ProviderConfig = z.infer<typeof ProviderConfigSchema>;

View file

@ -194,6 +194,34 @@ describe('harness config TOML loader', () => {
expect(config.raw?.['notifications']).toEqual({ claim_stale_after_ms: 15000 });
});
it('round-trips a custom registry source field on a provider', async () => {
const dir = makeTempDir();
const configPath = join(dir, 'round-trip.toml');
const toml = `
[providers.custom]
type = "openai"
base_url = "https://custom.example/v1"
api_key = "sk-test"
source = { kind = "apiJson", url = "https://registry.example/api.json", apiKey = "sk-registry" }
`;
const config = parseConfigString(toml, configPath);
expect(config.providers['custom']).toMatchObject({
type: 'openai',
baseUrl: 'https://custom.example/v1',
apiKey: 'sk-test',
source: { kind: 'apiJson', url: 'https://registry.example/api.json', apiKey: 'sk-registry' },
});
await writeConfigFile(configPath, config);
const text = await readFile(configPath, 'utf-8');
const roundTripped = parseConfigString(text, configPath);
expect(roundTripped.providers['custom']?.source).toEqual({
kind: 'apiJson',
url: 'https://registry.example/api.json',
apiKey: 'sk-registry',
});
});
it('loads defaults for absent files and writes typed fields without dropping raw sections', async () => {
const dir = makeTempDir();
const configPath = join(dir, 'config.toml');

View file

@ -0,0 +1,351 @@
import { readApiErrorMessage } from './api-error';
import { isRecord } from './utils';
import type { ManagedKimiConfigShape } from './managed-kimi-code';
export type { ManagedKimiConfigShape };
/**
* Identifies where a custom-registry-managed provider came from. The same
* `{url, apiKey}` pair may produce multiple providers (one per top-level entry
* in the api.json document) the refresh dispatcher groups by these fields to
* issue a single HTTP GET per source.
*/
export interface CustomRegistrySource {
readonly kind: 'apiJson';
readonly url: string;
readonly apiKey: string;
}
/**
* The kosong `ProviderConfig` union (`packages/kosong/src/providers/index.ts`)
* mirrors these literal values. `kimi` is included because the api.json schema
* permits it even though kokub itself only emits the other three.
*/
export type CustomRegistryProviderType =
| 'anthropic'
| 'openai'
| 'openai_responses'
| 'kimi';
export interface CustomRegistryModelEntry {
readonly id: string;
readonly name?: string;
readonly limit?: { context?: number; output?: number };
readonly tool_call?: boolean;
readonly reasoning?: boolean;
readonly modalities?: {
input?: readonly string[];
output?: readonly string[];
};
}
export interface CustomRegistryProviderEntry {
readonly id: string;
readonly name: string;
readonly api: string;
readonly type: CustomRegistryProviderType;
readonly env?: readonly string[];
readonly models: Record<string, CustomRegistryModelEntry>;
}
/**
* Tuned slightly below typical real values so the local compactor kicks in
* before the upstream rejects with a context-overflow 4xx. Users can override
* by editing `~/.kimi-code/config.toml`.
*/
export const CUSTOM_REGISTRY_DEFAULT_MAX_CONTEXT = 131072;
export const CUSTOM_REGISTRY_DEFAULT_CAPABILITIES = ['tool_use'] as const;
const ALLOWED_PROVIDER_TYPES: ReadonlySet<CustomRegistryProviderType> = new Set([
'anthropic',
'openai',
'openai_responses',
'kimi',
]);
export class CustomRegistryApiError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'CustomRegistryApiError';
this.status = status;
}
}
function isAllowedProviderType(value: unknown): value is CustomRegistryProviderType {
return typeof value === 'string' && ALLOWED_PROVIDER_TYPES.has(value as CustomRegistryProviderType);
}
function toStringArrayOrUndefined(value: unknown): readonly string[] | undefined {
if (value === undefined || value === null) return undefined;
if (!Array.isArray(value)) return undefined;
const out: string[] = [];
for (const item of value) {
if (typeof item !== 'string') return undefined;
out.push(item);
}
return out;
}
function toModelEntry(value: unknown): CustomRegistryModelEntry | undefined {
if (!isRecord(value)) return undefined;
const id = value['id'];
if (typeof id !== 'string' || id.length === 0) return undefined;
const entry: {
id: string;
name?: string;
limit?: { context?: number; output?: number };
tool_call?: boolean;
reasoning?: boolean;
modalities?: { input?: readonly string[]; output?: readonly string[] };
} = { id };
const name = value['name'];
if (typeof name === 'string' && name.length > 0) entry.name = name;
const limit = value['limit'];
if (isRecord(limit)) {
const context = limit['context'];
const output = limit['output'];
const parsedLimit: { context?: number; output?: number } = {};
if (typeof context === 'number' && Number.isFinite(context) && context > 0) {
parsedLimit.context = Math.floor(context);
}
if (typeof output === 'number' && Number.isFinite(output) && output > 0) {
parsedLimit.output = Math.floor(output);
}
if (parsedLimit.context !== undefined || parsedLimit.output !== undefined) {
entry.limit = parsedLimit;
}
}
if (typeof value['tool_call'] === 'boolean') entry.tool_call = value['tool_call'];
if (typeof value['reasoning'] === 'boolean') entry.reasoning = value['reasoning'];
const modalities = value['modalities'];
if (isRecord(modalities)) {
const input = toStringArrayOrUndefined(modalities['input']);
const output = toStringArrayOrUndefined(modalities['output']);
if (input !== undefined || output !== undefined) {
entry.modalities = {
...(input !== undefined ? { input } : {}),
...(output !== undefined ? { output } : {}),
};
}
}
return entry;
}
function toProviderEntry(value: unknown): CustomRegistryProviderEntry | undefined {
if (!isRecord(value)) return undefined;
const id = value['id'];
const name = value['name'];
const api = value['api'];
const type = value['type'];
const models = value['models'];
if (typeof id !== 'string' || id.length === 0) return undefined;
if (typeof name !== 'string' || name.length === 0) return undefined;
if (typeof api !== 'string' || api.length === 0) return undefined;
if (!isAllowedProviderType(type)) return undefined;
if (!isRecord(models)) return undefined;
const parsedModels: Record<string, CustomRegistryModelEntry> = {};
for (const [key, raw] of Object.entries(models)) {
const modelEntry = toModelEntry(raw);
if (modelEntry === undefined) continue;
parsedModels[key] = modelEntry;
}
const env = toStringArrayOrUndefined(value['env']);
return {
id,
name,
api,
type,
...(env !== undefined ? { env } : {}),
models: parsedModels,
};
}
/**
* Fetches and validates an api.json document. The returned record is keyed by
* the top-level provider key in the document (which may differ from
* `entry.id`); callers should iterate `Object.values` to apply each entry.
*/
export async function fetchCustomRegistry(
source: CustomRegistrySource,
fetchImpl: typeof fetch = fetch,
signal?: AbortSignal,
): Promise<Record<string, CustomRegistryProviderEntry>> {
const headers: Record<string, string> = {
Accept: 'application/json',
};
if (source.apiKey.length > 0) {
headers['Authorization'] = `Bearer ${source.apiKey}`;
}
const init: RequestInit = { headers };
if (signal !== undefined) init.signal = signal;
const response = await fetchImpl(source.url, init);
if (!response.ok) {
const message = await readApiErrorMessage(
response,
`Failed to fetch custom registry at ${source.url} (HTTP ${response.status}).`,
);
throw new CustomRegistryApiError(message, response.status);
}
const payload: unknown = await response.json();
if (!isRecord(payload)) {
throw new Error(
`Unexpected custom registry response at ${source.url}: expected a JSON object keyed by provider id.`,
);
}
const out: Record<string, CustomRegistryProviderEntry> = {};
for (const [key, raw] of Object.entries(payload)) {
const entry = toProviderEntry(raw);
if (entry === undefined) {
// Skip invalid/unknown provider entries instead of aborting the whole
// fetch, mirroring `toModelEntry`'s skip-on-invalid behavior. This keeps
// existing providers working when kokub adds a new provider type that
// this client doesn't yet recognize.
console.warn(
`[custom-registry] Skipping invalid entry "${key}" at ${source.url}: missing required fields or unsupported type (id, name, api, type, models).`,
);
continue;
}
out[key] = entry;
}
return out;
}
/**
* Derives kosong capability strings from the rich (optional) fields on a
* custom-registry model entry. Returns an empty array when none of the rich
* fields are present; callers are responsible for substituting the default
* (`CUSTOM_REGISTRY_DEFAULT_CAPABILITIES`) when this returns `[]`.
*/
export function capabilitiesFromCustomEntry(model: CustomRegistryModelEntry): string[] {
const caps = new Set<string>();
if (model.tool_call === true) caps.add('tool_use');
if (model.reasoning === true) caps.add('thinking');
if (model.modalities?.input?.includes('image') === true) caps.add('image_in');
if (model.modalities?.input?.includes('video') === true) caps.add('video_in');
if (model.modalities?.output?.includes('image') === true) caps.add('image_out');
if (model.modalities?.output?.includes('audio') === true) caps.add('audio_out');
return [...caps];
}
function hasRichCapabilityHints(model: CustomRegistryModelEntry): boolean {
return (
typeof model.tool_call === 'boolean' ||
typeof model.reasoning === 'boolean' ||
model.modalities !== undefined
);
}
function resolveMaxContextSize(model: CustomRegistryModelEntry): number {
const context = model.limit?.context;
const output = model.limit?.output;
if (typeof context === 'number' && Number.isInteger(context) && context > 0) {
return context;
}
if (typeof output === 'number' && Number.isInteger(output) && output > 0) {
return output;
}
return CUSTOM_REGISTRY_DEFAULT_MAX_CONTEXT;
}
function resolveCapabilities(model: CustomRegistryModelEntry): string[] {
if (hasRichCapabilityHints(model)) {
return capabilitiesFromCustomEntry(model);
}
return [...CUSTOM_REGISTRY_DEFAULT_CAPABILITIES];
}
/**
* Writes one custom-registry provider entry into the managed config in place.
* Mirrors `applyOpenPlatformConfig`'s shape: provider goes to `config.providers`
* keyed by `entry.id`, each model in `entry.models` becomes an alias under
* `config.models[\`${entry.id}/${modelId}\`]`. The `source` blob is parked on the
* provider object via `ManagedKimiProviderConfig`'s index signature so the
* refresh dispatcher can rediscover it later.
*/
export function applyCustomRegistryProvider(
config: ManagedKimiConfigShape,
entry: CustomRegistryProviderEntry,
source: CustomRegistrySource,
): void {
const providerKey = entry.id;
config.providers[providerKey] = {
type: entry.type,
baseUrl: entry.api,
apiKey: source.apiKey,
source,
};
const existingModels = config.models ?? {};
// Drop stale aliases for the same provider before re-populating, mirroring
// applyOpenPlatformConfig's refresh semantics.
for (const [key, alias] of Object.entries(existingModels)) {
if (isRecord(alias) && alias['provider'] === providerKey) {
delete existingModels[key];
}
}
for (const [modelKey, model] of Object.entries(entry.models)) {
const aliasKey = `${providerKey}/${modelKey}`;
const maxContextSize = resolveMaxContextSize(model);
const capabilities = resolveCapabilities(model);
const displayName =
typeof model.name === 'string' && model.name.length > 0 ? model.name : model.id;
existingModels[aliasKey] = {
provider: providerKey,
model: model.id,
maxContextSize,
capabilities,
displayName,
};
}
config.models = existingModels;
}
/**
* Removes a custom-registry provider and every model alias that referenced it.
* Clears `defaultModel` if it pointed at a removed alias. Mirrors
* `removeOpenPlatformConfig`.
*/
export function removeCustomRegistryProvider(
config: ManagedKimiConfigShape,
providerId: string,
): void {
delete config.providers[providerId];
let removedDefault = false;
const existingModels = config.models ?? {};
for (const [key, alias] of Object.entries(existingModels)) {
if (!isRecord(alias) || alias['provider'] !== providerId) continue;
delete existingModels[key];
if (config.defaultModel === key) removedDefault = true;
}
config.models = existingModels;
if (removedDefault) {
config.defaultModel = undefined;
}
if (config['defaultProvider'] === providerId) {
config['defaultProvider'] = undefined;
}
}

View file

@ -98,6 +98,22 @@ export type {
OpenPlatformDefinition,
} from './open-platform';
export {
applyCustomRegistryProvider,
capabilitiesFromCustomEntry,
CustomRegistryApiError,
CUSTOM_REGISTRY_DEFAULT_CAPABILITIES,
CUSTOM_REGISTRY_DEFAULT_MAX_CONTEXT,
fetchCustomRegistry,
removeCustomRegistryProvider,
} from './custom-registry';
export type {
CustomRegistryModelEntry,
CustomRegistryProviderEntry,
CustomRegistryProviderType,
CustomRegistrySource,
} from './custom-registry';
export { KimiOAuthToolkit, resolveKimiTokenStorageName } from './toolkit';
export type {
AuthManagedUsageResult,

View file

@ -0,0 +1,410 @@
import { describe, expect, it, vi } from 'vitest';
import {
applyCustomRegistryProvider,
CUSTOM_REGISTRY_DEFAULT_CAPABILITIES,
CUSTOM_REGISTRY_DEFAULT_MAX_CONTEXT,
capabilitiesFromCustomEntry,
CustomRegistryApiError,
fetchCustomRegistry,
removeCustomRegistryProvider,
type CustomRegistryProviderEntry,
type CustomRegistrySource,
type ManagedKimiConfigShape,
} from '../src/custom-registry';
function makeKokubResponseBody(): Record<string, CustomRegistryProviderEntry> {
return {
'registry_chat-completions': {
id: 'registry_chat-completions',
name: 'Sample Registry (chat completions)',
api: 'https://registry.example.test/v1',
type: 'openai',
models: {
'gpt-5.5': { id: 'gpt-5.5', name: 'GPT 5.5' },
'claude-opus-4-7': { id: 'claude-opus-4-7', name: 'Claude Opus 4.7' },
},
},
'registry_messages': {
id: 'registry_messages',
name: 'Sample Registry (messages)',
api: 'https://registry.example.test',
type: 'anthropic',
models: {
'claude-opus-4-7': { id: 'claude-opus-4-7', name: 'Claude Opus 4.7' },
},
},
'registry_responses': {
id: 'registry_responses',
name: 'Sample Registry (responses)',
api: 'https://registry.example.test/v1',
type: 'openai_responses',
models: {
'gpt-5.5': { id: 'gpt-5.5', name: 'GPT 5.5' },
},
},
};
}
function makeJsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
const KOKUB_SOURCE: CustomRegistrySource = {
kind: 'apiJson',
url: 'https://registry.example.test/v1/models/api.json',
apiKey: 'sk-token',
};
describe('fetchCustomRegistry', () => {
it('parses a kokub-shaped 200 response into three providers', async () => {
const fetchMock = vi.fn(async () => makeJsonResponse(makeKokubResponseBody()));
const result = await fetchCustomRegistry(
KOKUB_SOURCE,
fetchMock as unknown as typeof fetch,
);
expect(Object.keys(result)).toHaveLength(3);
expect(result['registry_chat-completions']?.type).toBe('openai');
expect(result['registry_messages']?.type).toBe('anthropic');
expect(result['registry_responses']?.type).toBe('openai_responses');
expect(result['registry_chat-completions']?.models['gpt-5.5']).toEqual({
id: 'gpt-5.5',
name: 'GPT 5.5',
});
expect(fetchMock).toHaveBeenCalledWith(
KOKUB_SOURCE.url,
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer sk-token',
Accept: 'application/json',
}),
}),
);
});
it('omits the Authorization header when the apiKey is empty', async () => {
const fetchMock = vi.fn(async () => makeJsonResponse(makeKokubResponseBody()));
await fetchCustomRegistry(
{ kind: 'apiJson', url: KOKUB_SOURCE.url, apiKey: '' },
fetchMock as unknown as typeof fetch,
);
expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
const headers = call[1].headers as Record<string, string>;
expect(headers['Authorization']).toBeUndefined();
expect(headers['Accept']).toBe('application/json');
});
it('forwards an AbortSignal when provided', async () => {
const fetchMock = vi.fn(async () => makeJsonResponse(makeKokubResponseBody()));
const controller = new AbortController();
await fetchCustomRegistry(
KOKUB_SOURCE,
fetchMock as unknown as typeof fetch,
controller.signal,
);
expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(call[1].signal).toBe(controller.signal);
});
it('throws CustomRegistryApiError with status on 401', async () => {
const fetchMock = vi.fn(
async () => makeJsonResponse({ error: { message: 'invalid bearer' } }, 401),
);
const error = await fetchCustomRegistry(
KOKUB_SOURCE,
fetchMock as unknown as typeof fetch,
).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(CustomRegistryApiError);
expect((error as CustomRegistryApiError).status).toBe(401);
expect((error as Error).message).toBe('invalid bearer');
});
it('throws when the payload is not an object', async () => {
const fetchMock = vi.fn(async () => makeJsonResponse(['not', 'an', 'object']));
await expect(
fetchCustomRegistry(KOKUB_SOURCE, fetchMock as unknown as typeof fetch),
).rejects.toThrow(/expected a JSON object/);
});
it('skips invalid entries and keeps valid ones', async () => {
const goodEntry = makeKokubResponseBody()['registry_chat-completions'];
const fetchMock = vi.fn(
async () =>
makeJsonResponse({
'broken-entry': { id: 'broken-entry', name: 'Broken' },
'unknown-type': {
id: 'unknown-type',
name: 'Unknown Type',
api: 'https://example.test/v1',
type: 'google-genai',
models: { 'm-1': { id: 'm-1' } },
},
'registry_chat-completions': goodEntry,
}),
);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const result = await fetchCustomRegistry(
KOKUB_SOURCE,
fetchMock as unknown as typeof fetch,
);
expect(Object.keys(result)).toEqual(['registry_chat-completions']);
expect(result['broken-entry']).toBeUndefined();
expect(result['unknown-type']).toBeUndefined();
expect(result['registry_chat-completions']?.type).toBe('openai');
expect(warnSpy).toHaveBeenCalledTimes(2);
const warnings = warnSpy.mock.calls.map((args) => String(args[0]));
expect(warnings.some((m) => m.includes('broken-entry'))).toBe(true);
expect(warnings.some((m) => m.includes('unknown-type'))).toBe(true);
expect(warnings.every((m) => m.includes(KOKUB_SOURCE.url))).toBe(true);
} finally {
warnSpy.mockRestore();
}
});
});
describe('applyCustomRegistryProvider', () => {
it('writes provider + model aliases for a kokub-shaped entry with default fallbacks', () => {
const config: ManagedKimiConfigShape = { providers: {} };
const entry: CustomRegistryProviderEntry = {
id: 'registry_chat-completions',
name: 'Sample Registry (chat completions)',
api: 'https://registry.example.test/v1',
type: 'openai',
models: {
'gpt-5.5': { id: 'gpt-5.5', name: 'GPT 5.5' },
'claude-opus-4-7': { id: 'claude-opus-4-7', name: 'Claude Opus 4.7' },
},
};
applyCustomRegistryProvider(config, entry, KOKUB_SOURCE);
expect(config.providers['registry_chat-completions']).toEqual({
type: 'openai',
baseUrl: 'https://registry.example.test/v1',
apiKey: 'sk-token',
source: KOKUB_SOURCE,
});
const gpt = config.models?.['registry_chat-completions/gpt-5.5'];
expect(gpt).toBeDefined();
expect(gpt).toMatchObject({
provider: 'registry_chat-completions',
model: 'gpt-5.5',
maxContextSize: CUSTOM_REGISTRY_DEFAULT_MAX_CONTEXT,
displayName: 'GPT 5.5',
});
expect(gpt).toMatchObject({ maxContextSize: 131072 });
expect((gpt as { capabilities: string[] }).capabilities).toEqual([
...CUSTOM_REGISTRY_DEFAULT_CAPABILITIES,
]);
const claude = config.models?.['registry_chat-completions/claude-opus-4-7'];
expect(claude).toBeDefined();
expect((claude as { displayName: string }).displayName).toBe('Claude Opus 4.7');
});
it('falls back to the model id for displayName when name is absent', () => {
const config: ManagedKimiConfigShape = { providers: {} };
const entry: CustomRegistryProviderEntry = {
id: 'demo',
name: 'Demo',
api: 'https://demo.example/v1',
type: 'openai',
models: {
'm-1': { id: 'm-1' },
},
};
applyCustomRegistryProvider(config, entry, {
kind: 'apiJson',
url: 'https://demo.example/api.json',
apiKey: 'x',
});
expect((config.models?.['demo/m-1'] as { displayName: string }).displayName).toBe('m-1');
});
it('derives rich capabilities and limit-based context size when rich fields are present', () => {
const config: ManagedKimiConfigShape = { providers: {} };
const entry: CustomRegistryProviderEntry = {
id: 'rich',
name: 'Rich Provider',
api: 'https://rich.example/v1',
type: 'openai',
models: {
'rich-vision': {
id: 'rich-vision',
name: 'Rich Vision',
tool_call: true,
reasoning: true,
modalities: { input: ['text', 'image'], output: ['text'] },
limit: { context: 200000, output: 8192 },
},
},
};
applyCustomRegistryProvider(config, entry, {
kind: 'apiJson',
url: 'https://rich.example/api.json',
apiKey: 'sk-rich',
});
const alias = config.models?.['rich/rich-vision'] as {
maxContextSize: number;
capabilities: string[];
};
expect(alias.maxContextSize).toBe(200000);
expect(alias.capabilities).toEqual(expect.arrayContaining(['tool_use', 'thinking', 'image_in']));
expect(alias.capabilities).not.toContain('image_out');
});
it('clears stale aliases for the same provider before re-populating', () => {
const config: ManagedKimiConfigShape = {
providers: {
'registry_chat-completions': {
type: 'openai',
baseUrl: 'https://registry.example.test/v1',
apiKey: 'sk-old',
},
},
models: {
'registry_chat-completions/stale-model': {
provider: 'registry_chat-completions',
model: 'stale-model',
maxContextSize: 1000,
},
'other/keepme': {
provider: 'other',
model: 'keepme',
maxContextSize: 1000,
},
},
};
applyCustomRegistryProvider(
config,
{
id: 'registry_chat-completions',
name: 'Sample Registry (chat completions)',
api: 'https://registry.example.test/v1',
type: 'openai',
models: {
'gpt-5.5': { id: 'gpt-5.5', name: 'GPT 5.5' },
},
},
KOKUB_SOURCE,
);
expect(config.models?.['registry_chat-completions/stale-model']).toBeUndefined();
expect(config.models?.['registry_chat-completions/gpt-5.5']).toBeDefined();
expect(config.models?.['other/keepme']).toBeDefined();
});
});
describe('removeCustomRegistryProvider', () => {
it('removes the provider and every alias for it, and clears matching defaultModel', () => {
const config: ManagedKimiConfigShape = {
providers: {
'registry_chat-completions': {
type: 'openai',
baseUrl: 'https://registry.example.test/v1',
apiKey: 'sk-token',
},
other: { type: 'openai', baseUrl: 'https://other.test/v1', apiKey: 'sk-other' },
},
models: {
'registry_chat-completions/gpt-5.5': {
provider: 'registry_chat-completions',
model: 'gpt-5.5',
maxContextSize: 131072,
},
'registry_chat-completions/claude-opus-4-7': {
provider: 'registry_chat-completions',
model: 'claude-opus-4-7',
maxContextSize: 131072,
},
'other/keepme': { provider: 'other', model: 'keepme', maxContextSize: 1000 },
},
defaultModel: 'registry_chat-completions/gpt-5.5',
};
removeCustomRegistryProvider(config, 'registry_chat-completions');
expect(config.providers['registry_chat-completions']).toBeUndefined();
expect(config.providers['other']).toBeDefined();
expect(config.models?.['registry_chat-completions/gpt-5.5']).toBeUndefined();
expect(config.models?.['registry_chat-completions/claude-opus-4-7']).toBeUndefined();
expect(config.models?.['other/keepme']).toBeDefined();
expect(config.defaultModel).toBeUndefined();
});
it('leaves defaultModel intact when it belongs to another provider', () => {
const config: ManagedKimiConfigShape = {
providers: {
'registry_chat-completions': {
type: 'openai',
baseUrl: 'https://registry.example.test/v1',
apiKey: 'sk-token',
},
},
models: {
'registry_chat-completions/gpt-5.5': {
provider: 'registry_chat-completions',
model: 'gpt-5.5',
maxContextSize: 131072,
},
},
defaultModel: 'other/keepme',
};
removeCustomRegistryProvider(config, 'registry_chat-completions');
expect(config.defaultModel).toBe('other/keepme');
});
});
describe('capabilitiesFromCustomEntry', () => {
it('returns an empty array when no rich fields are present', () => {
expect(capabilitiesFromCustomEntry({ id: 'm' })).toEqual([]);
});
it('maps individual fields to kosong capability strings', () => {
expect(
capabilitiesFromCustomEntry({
id: 'm',
tool_call: true,
reasoning: true,
modalities: { input: ['text', 'image', 'video'], output: ['text', 'image', 'audio'] },
}),
).toEqual(expect.arrayContaining(['tool_use', 'thinking', 'image_in', 'video_in', 'image_out', 'audio_out']));
});
it('omits capabilities that are explicitly false', () => {
expect(
capabilitiesFromCustomEntry({
id: 'm',
tool_call: false,
reasoning: false,
}),
).toEqual([]);
});
});