mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
a1dfbfeb16
commit
42bb9141d8
24 changed files with 2580 additions and 310 deletions
6
.changeset/provider-management.md
Normal file
6
.changeset/provider-management.md
Normal 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.
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
357
apps/kimi-code/src/tui/commands/provider.ts
Normal file
357
apps/kimi-code/src/tui/commands/provider.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 =>
|
||||
|
|
|
|||
460
apps/kimi-code/src/tui/components/dialogs/provider-manager.ts
Normal file
460
apps/kimi-code/src/tui/components/dialogs/provider-manager.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
291
apps/kimi-code/src/tui/utils/refresh-providers.ts
Normal file
291
apps/kimi-code/src/tui/utils/refresh-providers.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 token;CLI 会拉取 registry 内容,自动创建对应的 `[providers.<name>]` 与 `[models.<alias>]` 条目,并刷新可用模型列表。
|
||||
|
||||
对通过 `/connect` 配置的供应商,`/logout` 同样有效:它会从 `config.toml` 中删除对应的 `[providers.<name>]` 配置块。
|
||||
添加供应商或切换模型时,**标签页式模型选择器** 会把可用模型按供应商拆分为多个标签页。按 `Tab` / `Shift-Tab` 切换标签页,随后用 ↑/↓ 与 `Enter` 选择想要的模型即可。
|
||||
|
||||
::: warning 注意
|
||||
通过 `/login` 登录的 Kimi Code OAuth 供应商(托管账号)在 `/provider` 中会被故意隐藏;该账号请通过 `/login` 与 `/logout` 管理。
|
||||
:::
|
||||
|
||||
## `kimi`
|
||||
|
||||
|
|
|
|||
|
|
@ -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` 调起的外部编辑器。 | 是 |
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
351
packages/oauth/src/custom-registry.ts
Normal file
351
packages/oauth/src/custom-registry.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
410
packages/oauth/test/custom-registry.test.ts
Normal file
410
packages/oauth/test/custom-registry.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue