diff --git a/.changeset/provider-management.md b/.changeset/provider-management.md new file mode 100644 index 00000000..0756258b --- /dev/null +++ b/.changeset/provider-management.md @@ -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. diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 2f512d03..c70bc8a2 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -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); diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 3bd878b0..2dc39ba1 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -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; diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts new file mode 100644 index 00000000..7690a805 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const value = await promptCustomRegistryImport(host); + if (value === undefined) return false; + + const source: CustomRegistrySource = { + kind: 'apiJson', + url: value.url, + apiKey: value.apiKey, + }; + + let entries: Record }>; + 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[0], + entry as Parameters[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); + }); +} diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index faf76b57..4f4b7584 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -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'], diff --git a/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts b/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts new file mode 100644 index 00000000..9f10471a --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts @@ -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' }); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index f399f4d5..38ce8d87 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -10,6 +10,7 @@ import chalk from 'chalk'; import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '#/constant/app'; import type { ColorPalette } from '#/tui/theme/colors'; +import { 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): 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 => diff --git a/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts b/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts new file mode 100644 index 00000000..8ff2a21a --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts @@ -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; + /** 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(); + + 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); +} diff --git a/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts new file mode 100644 index 00000000..5d04b590 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/tabbed-model-selector.ts @@ -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; + 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(); + 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 = {}; + 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, +): 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); +} diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index 5b5f9562..4f1fdc10 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -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; + } } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 9702ed78..922b83ad 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -401,6 +401,26 @@ export class KimiTUI { this.refreshTerminalThemeTracking(); } + private async refreshProviderModelsInBackground(): Promise { + 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 { if (this.startupNotice !== undefined) { this.showStatus(this.startupNotice); @@ -436,6 +456,7 @@ export class KimiTUI { private async init(): Promise { await this.authFlow.refreshAvailableModels(); + void this.refreshProviderModelsInBackground(); const { startup } = this.options; const { workDir } = this.state.appState; diff --git a/apps/kimi-code/src/tui/utils/refresh-providers.ts b/apps/kimi-code/src/tui/utils/refresh-providers.ts new file mode 100644 index 00000000..3bbda132 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/refresh-providers.ts @@ -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; + removeProvider(providerId: string): Promise; + setConfig(patch: KimiConfigPatch): Promise; + resolveOAuthToken(providerName: string, oauthRef?: OAuthRef): Promise; +} + +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; + 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 { + const ids = new Set(); + for (const alias of Object.values(config.models ?? {})) { + if (alias.provider === providerId && alias.model.length > 0) { + ids.add(alias.model); + } + } + return ids; +} + +function setsEqual(a: Set, b: Set): 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, newIds: Set): { 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 { + 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(); + 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 }; +} diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts index 07381c0b..a02f6f08 100644 --- a/apps/kimi-code/test/tui/commands/resolve.test.ts +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -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', diff --git a/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts b/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts index 13558ab6..7f47bbe1 100644 --- a/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/choice-picker.test.ts @@ -1,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 { - const models: Record = {}; - for (let i = 0; i < count; i++) { - const id = `model${String(i).padStart(2, '0')}`; - models[`prov/${id}`] = { - provider: 'prov', - model: id, - maxContextSize: 1000, - capabilities: ['thinking'], - }; - } - return models; - } - - function makeSelector(models: Record, currentThinking = true) { - const onSelect = vi.fn(); - const onCancel = vi.fn(); - const firstAlias = Object.keys(models)[0] ?? ''; - const selector = new ModelSelectorComponent({ - models, - currentValue: firstAlias, - currentThinking, - colors: darkColors, - searchable: true, - onSelect, - onCancel, - }); - return { selector, onSelect, onCancel }; - } - - it('filters models as the user types', () => { - const { selector } = makeSelector({ - 'p/alpha': { provider: 'p', model: 'alpha', maxContextSize: 1000 }, - 'p/beta': { provider: 'p', model: 'beta', maxContextSize: 1000 }, - 'p/gamma': { provider: 'p', model: 'gamma', maxContextSize: 1000 }, - }); - for (const ch of 'beta') selector.handleInput(ch); - const out = rendered(selector); - expect(out).toContain('Search: beta'); - expect(out).toContain('beta (p)'); - expect(out).not.toContain('alpha (p)'); - expect(out).not.toContain('gamma (p)'); - }); - - it('pages with PageDown/PageUp while Left/Right still toggle thinking', () => { - const { selector } = makeSelector(buildModels(20)); - - expect(rendered(selector)).toContain('Page 1/3'); - expect(rendered(selector)).toContain('model00 (prov)'); - expect(rendered(selector)).not.toContain('model08 (prov)'); - - selector.handleInput(PAGE_DOWN); - expect(rendered(selector)).toContain('Page 2/3'); - expect(rendered(selector)).toContain('model08 (prov)'); - - // Right toggles thinking off and must NOT change the page. - selector.handleInput(RIGHT); - expect(rendered(selector)).toContain('Page 2/3'); - expect(rendered(selector)).toContain('[ Off ]'); - - // Left toggles thinking back on, page still unchanged. - selector.handleInput(LEFT); - expect(rendered(selector)).toContain('Page 2/3'); - expect(rendered(selector)).toContain('[ On ]'); - - selector.handleInput(PAGE_UP); - expect(rendered(selector)).toContain('Page 1/3'); - }); -}); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 58c6f15a..7e547d50 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -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({ diff --git a/docs/en/configuration/providers.md b/docs/en/configuration/providers.md index 0da2ebf0..e9047308 100644 --- a/docs/en/configuration/providers.md +++ b/docs/en/configuration/providers.md @@ -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.]` and `[models.]` 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=` 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.]` and `[models.]` entries, and refreshes the available model list automatically. -`/logout` works on `/connect`-configured providers too: it removes the corresponding `[providers.]` 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` diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 8317a490..4f2a56a0 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -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=]` | — | 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 | diff --git a/docs/zh/configuration/providers.md b/docs/zh/configuration/providers.md index 22b2acf9..283ee8b4 100644 --- a/docs/zh/configuration/providers.md +++ b/docs/zh/configuration/providers.md @@ -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.]` 与 `[models.]` 写入 `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=`:从自定义地址读取模型目录(格式需与默认目录一致),只接受 `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.]` 与 `[models.]` 条目,并刷新可用模型列表。 -对通过 `/connect` 配置的供应商,`/logout` 同样有效:它会从 `config.toml` 中删除对应的 `[providers.]` 配置块。 +添加供应商或切换模型时,**标签页式模型选择器** 会把可用模型按供应商拆分为多个标签页。按 `Tab` / `Shift-Tab` 切换标签页,随后用 ↑/↓ 与 `Enter` 选择想要的模型即可。 + +::: warning 注意 +通过 `/login` 登录的 Kimi Code OAuth 供应商(托管账号)在 `/provider` 中会被故意隐藏;该账号请通过 `/login` 与 `/logout` 管理。 +::: ## `kimi` diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 7cd8990b..af504f2c 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -14,8 +14,8 @@ | --- | --- | --- | --- | | `/login` | — | 选择账号或平台并登录:Kimi Code 走 OAuth 验证码流程,Kimi Platform 通过 API 密钥登录。 | 否 | | `/logout` | — | 清除当前所选账号的凭据(Kimi Code OAuth 凭据,或对应开放平台的供应商配置)。 | 否 | -| `/connect [--refresh] [--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` 调起的外部编辑器。 | 是 | diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index e55d0f0b..d894ef1d 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -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; diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index aeea428c..947469e3 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -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'); diff --git a/packages/oauth/src/custom-registry.ts b/packages/oauth/src/custom-registry.ts new file mode 100644 index 00000000..565fea4f --- /dev/null +++ b/packages/oauth/src/custom-registry.ts @@ -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; +} + +/** + * 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 = 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 = {}; + 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> { + const headers: Record = { + 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 = {}; + 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(); + 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; + } +} diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 22f149bd..45df8a78 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -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, diff --git a/packages/oauth/test/custom-registry.test.ts b/packages/oauth/test/custom-registry.test.ts new file mode 100644 index 00000000..a4d6047e --- /dev/null +++ b/packages/oauth/test/custom-registry.test.ts @@ -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 { + 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; + 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([]); + }); +});