From 5f133d6cb2c39c9d4ff6322465f724487519dbbf Mon Sep 17 00:00:00 2001 From: Yizheng Weng Date: Mon, 6 Jul 2026 20:24:36 +0800 Subject: [PATCH] feat(tui): add OpenAI-compatible provider setup --- .../openai-compatible-provider-setup.md | 5 + apps/kimi-code/src/tui/commands/provider.ts | 245 ++++++++++++++- .../dialogs/openai-provider-import.ts | 282 ++++++++++++++++++ .../components/dialogs/provider-manager.ts | 9 +- .../dialogs/provider-manager.test.ts | 1 + docs/en/configuration/providers.md | 5 +- docs/zh/configuration/providers.md | 5 +- 7 files changed, 544 insertions(+), 8 deletions(-) create mode 100644 .changeset/openai-compatible-provider-setup.md create mode 100644 apps/kimi-code/src/tui/components/dialogs/openai-provider-import.ts diff --git a/.changeset/openai-compatible-provider-setup.md b/.changeset/openai-compatible-provider-setup.md new file mode 100644 index 0000000000..bca2a585fb --- /dev/null +++ b/.changeset/openai-compatible-provider-setup.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add native /provider setup for OpenAI-compatible providers. Use /provider to enter a provider base URL and API key, fetch models, and select them through /model. diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts index eb416a54e6..dc4534ef09 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -13,6 +13,8 @@ import { fetchCatalog, inferWireType, type Catalog, + type ModelAlias, + type ProviderConfig, type ThinkingEffort, } from '@moonshot-ai/kimi-code-sdk'; @@ -21,6 +23,11 @@ import { CustomRegistryImportDialogComponent, type CustomRegistryImportResult, } from '../components/dialogs/custom-registry-import'; +import { + OpenAIProviderImportDialogComponent, + type OpenAIProviderImportResult, + type OpenAIProviderImportValue, +} from '../components/dialogs/openai-provider-import'; import { ProviderManagerComponent, type ProviderManagerOptions, @@ -39,6 +46,10 @@ import type { SlashCommandHost } from './dispatch'; // /provider command // --------------------------------------------------------------------------- +const DEFAULT_OPENAI_CONTEXT_TOKENS = 131_072; +const OPENAI_THINKING_EFFORTS = ['low', 'medium', 'high', 'xhigh'] as const; +const DEFAULT_OPENAI_THINKING_EFFORT = 'medium'; + export async function handleProviderCommand(host: SlashCommandHost): Promise { const options = buildProviderManagerOptions(host); const component = new ProviderManagerComponent(options); @@ -56,6 +67,12 @@ function buildProviderManagerOptions(host: SlashCommandHost): ProviderManagerOpt host.showError(`Add provider failed: ${formatErrorMessage(error)}`); }); }, + onEditProvider: (providerId) => { + void handleOpenAIProviderAddOrEditViaDialog(host, providerId).catch((error: unknown) => { + host.showError(`Edit provider failed: ${formatErrorMessage(error)}`); + reopenProviderManager(host); + }); + }, onDeleteSource: (providerIds) => { void handleProviderManagerDeleteSource(host, providerIds).catch((error: unknown) => { host.showError(`Remove provider failed: ${formatErrorMessage(error)}`); @@ -111,6 +128,12 @@ async function handleProviderAdd(host: SlashCommandHost): Promise { return; } + if (source === 'openai') { + const handled = await handleOpenAIProviderAddOrEditViaDialog(host); + if (!handled) reopenProviderManager(host); + return; + } + if (source === 'known') { await handleCatalogProviderAdd(host); return; @@ -127,19 +150,26 @@ function reopenProviderManager(host: SlashCommandHost): void { host.mountEditorReplacement(component); } +type ProviderAddSource = 'openai' | 'known' | 'custom'; + function promptProviderAddSource( host: SlashCommandHost, -): Promise<'known' | 'custom' | undefined> { +): Promise { return new Promise((resolve) => { const picker = new ChoicePickerComponent({ title: 'Add provider', options: [ + { value: 'openai', label: 'OpenAI-compatible provider' }, { value: 'known', label: 'Known third-party provider' }, { value: 'custom', label: 'Custom registry (api.json)' }, ], onSelect: (value) => { host.restoreEditor(); - resolve(value === 'known' || value === 'custom' ? value : undefined); + resolve( + value === 'openai' || value === 'known' || value === 'custom' + ? value + : undefined, + ); }, onCancel: () => { host.restoreEditor(); @@ -150,6 +180,217 @@ function promptProviderAddSource( }); } +async function handleOpenAIProviderAddOrEditViaDialog( + host: SlashCommandHost, + providerId?: string, +): Promise { + const existingProvider = providerId + ? host.state.appState.availableProviders[providerId] + : undefined; + if (providerId !== undefined && existingProvider !== undefined && existingProvider.type !== 'openai') { + host.showError(`Provider "${providerId}" is not OpenAI-compatible.`); + return false; + } + + const value = await promptOpenAIProviderImport(host, providerId, existingProvider); + if (value === undefined) return false; + + const baseUrl = normalizeOpenAIBaseUrl(value.baseUrl); + const spinner = host.showProgressSpinner(`Fetching models from ${baseUrl}/models`); + let modelIds: readonly string[]; + try { + modelIds = await fetchOpenAICompatibleModels(baseUrl, value.apiKey); + spinner.stop({ ok: true, label: `Loaded ${String(modelIds.length)} models.` }); + } catch (error) { + spinner.stop({ ok: false, label: 'Failed to load models.' }); + host.showError(`Failed to fetch models: ${formatErrorMessage(error)}`); + return false; + } + + if (modelIds.length === 0) { + host.showError('The provider returned no usable models.'); + return false; + } + + const config = await host.harness.getConfig(); + const isRenaming = providerId !== undefined && providerId !== value.providerId; + if (isRenaming && config.providers[value.providerId] !== undefined) { + host.showError(`Provider "${value.providerId}" already exists.`); + return false; + } + + if (providerId !== undefined && config.providers[providerId] !== undefined) { + await host.harness.removeProvider(providerId); + } else if (config.providers[value.providerId] !== undefined) { + await host.harness.removeProvider(value.providerId); + } + + const nextConfig = await host.harness.getConfig(); + applyOpenAICompatibleProvider(nextConfig, { + providerId: value.providerId, + baseUrl, + apiKey: value.apiKey, + modelIds, + }); + + await host.harness.setConfig({ + providers: nextConfig.providers, + models: nextConfig.models, + }); + await host.authFlow.refreshConfigAfterLogin(); + host.track('connect', { provider: value.providerId, method: 'openai-compatible' }); + host.showStatus( + `${providerId === undefined ? 'Provider added' : 'Provider updated'}: ${value.providerId} (${String(modelIds.length)} models)`, + 'success', + ); + + const stateModels = await host.harness.getConfig().then((c) => c.models ?? {}); + const firstAlias = Object.keys(stateModels).find((alias) => + alias.startsWith(`${value.providerId}/`), + ); + const selector = new TabbedModelSelectorComponent({ + models: stateModels, + currentValue: host.state.appState.model, + selectedValue: firstAlias, + currentThinkingEffort: host.state.appState.thinkingEffort, + initialTabId: value.providerId, + onSelect: ({ alias, thinking }) => { + host.restoreEditor(); + void setDefaultModel(host, alias, thinking).catch((error: unknown) => { + host.showError(`Set default model failed: ${formatErrorMessage(error)}`); + }); + }, + onCancel: () => { + host.restoreEditor(); + }, + }); + host.mountEditorReplacement(selector); + return true; +} + +function promptOpenAIProviderImport( + host: SlashCommandHost, + providerId: string | undefined, + provider: ProviderConfig | undefined, +): Promise { + return new Promise((resolve) => { + const dialog = new OpenAIProviderImportDialogComponent( + (result: OpenAIProviderImportResult) => { + host.restoreEditor(); + resolve(result.kind === 'ok' ? result.value : undefined); + }, + { + providerId, + baseUrl: provider?.baseUrl, + apiKey: provider?.apiKey, + }, + ); + host.mountEditorReplacement(dialog); + }); +} + +function normalizeOpenAIBaseUrl(raw: string): string { + try { + return new URL(raw.trim()).toString().replace(/\/+$/, ''); + } catch { + throw new Error('Base URL must be a valid URL.'); + } +} + +async function fetchOpenAICompatibleModels( + baseUrl: string, + apiKey: string, +): Promise { + const response = await fetch(`${baseUrl}/models`, { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + }); + if (!response.ok) { + throw new Error(`HTTP ${String(response.status)} ${response.statusText}`.trim()); + } + + const payload: unknown = await response.json(); + if (typeof payload !== 'object' || payload === null || !('data' in payload)) { + throw new Error('Unexpected /models response: missing data array.'); + } + const data = (payload as { readonly data?: unknown }).data; + if (!Array.isArray(data)) { + throw new Error('Unexpected /models response: data is not an array.'); + } + + const seen = new Set(); + const ids: string[] = []; + for (const item of data) { + const id = + typeof item === 'object' && item !== null && 'id' in item + ? (item as { readonly id?: unknown }).id + : undefined; + if (typeof id !== 'string') continue; + const trimmed = id.trim(); + if (trimmed.length === 0 || seen.has(trimmed)) continue; + seen.add(trimmed); + ids.push(trimmed); + } + return ids; +} + +function applyOpenAICompatibleProvider( + config: { providers: Record; models?: Record }, + opts: { + readonly providerId: string; + readonly baseUrl: string; + readonly apiKey: string; + readonly modelIds: readonly string[]; + }, +): void { + config.providers[opts.providerId] = { + type: 'openai', + baseUrl: opts.baseUrl, + apiKey: opts.apiKey, + source: { kind: 'openaiModels', baseUrl: opts.baseUrl }, + }; + + const models = config.models ?? {}; + for (const modelId of opts.modelIds) { + models[`${opts.providerId}/${modelId}`] = openAICompatibleModelAlias( + opts.providerId, + modelId, + ); + } + config.models = models; +} + +function openAICompatibleModelAlias(providerId: string, modelId: string): ModelAlias { + const thinking = isLikelyOpenAIThinkingModel(modelId); + return { + provider: providerId, + model: modelId, + maxContextSize: DEFAULT_OPENAI_CONTEXT_TOKENS, + capabilities: thinking ? ['tool_use', 'thinking'] : ['tool_use'], + displayName: modelId, + ...(thinking + ? { + supportEfforts: [...OPENAI_THINKING_EFFORTS], + defaultEffort: DEFAULT_OPENAI_THINKING_EFFORT, + } + : {}), + }; +} + +function isLikelyOpenAIThinkingModel(modelId: string): boolean { + const normalized = modelId.toLowerCase(); + const finalSegment = normalized.split('/').at(-1) ?? normalized; + return ( + normalized.includes('openai/') || + normalized.includes('/gpt-') || + finalSegment.startsWith('gpt-') || + finalSegment.startsWith('chatgpt-') || + /^o[134](?:-|$)/.test(finalSegment) + ); +} + async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { const controller = new AbortController(); const cancel = (): void => { diff --git a/apps/kimi-code/src/tui/components/dialogs/openai-provider-import.ts b/apps/kimi-code/src/tui/components/dialogs/openai-provider-import.ts new file mode 100644 index 0000000000..3fb140058b --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/openai-provider-import.ts @@ -0,0 +1,282 @@ +/** + * OpenAIProviderImportDialog — native `/provider` form for adding or editing + * an OpenAI-compatible provider. It collects provider id, base URL, and API key, + * then the command handler fetches `${baseUrl}/models` and writes model aliases. + */ + +import { + Container, + Input, + Key, + matchesKey, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@moonshot-ai/pi-tui'; + +import { currentTheme } from '#/tui/theme'; + +export interface OpenAIProviderImportValue { + readonly providerId: string; + readonly baseUrl: string; + readonly apiKey: string; +} + +export type OpenAIProviderImportResult = + | { readonly kind: 'ok'; readonly value: OpenAIProviderImportValue } + | { readonly kind: 'cancel' }; + +const TITLE_ADD = 'Add OpenAI-compatible provider'; +const TITLE_EDIT = 'Edit OpenAI-compatible provider'; +const SUBTITLE_DEFAULT = 'Enter provider details. Models will be fetched automatically.'; +const SUBTITLE_PROVIDER_EMPTY = 'Provider ID cannot be empty.'; +const SUBTITLE_PROVIDER_INVALID = 'Provider ID may only contain letters, numbers, dot, underscore, colon, or dash.'; +const SUBTITLE_BASE_URL_EMPTY = 'Base URL cannot be empty.'; +const SUBTITLE_API_KEY_EMPTY = 'API key cannot be empty.'; +const FOOTER_NOT_LAST = 'Tab / Up Down to switch · Enter for next field · Esc to cancel'; +const FOOTER_LAST = 'Tab / Up Down to switch · Enter to submit · Esc to cancel'; + +const PROVIDER_ID_RE = /^[A-Za-z0-9._:-]+$/; + +type FieldId = 'providerId' | 'baseUrl' | 'apiKey'; +type Hint = 'none' | 'provider-empty' | 'provider-invalid' | 'base-url-empty' | 'api-key-empty'; + +function maskInputLine(raw: string): string { + const prefix = '> '; + if (!raw.startsWith(prefix)) return raw; + + let end = raw.length; + while (end > prefix.length && raw[end - 1] === ' ') { + end--; + } + const padding = raw.slice(end); + const content = raw.slice(prefix.length, end); + + const parts = content.split(/(\u001B(?:\[[0-9;]*m|_pi:c\u0007))/); + const maskedContent = parts + .map((part, index) => { + if (index % 2 === 1) return part; + return part.replaceAll(/[^ ]/g, '*'); + }) + .join(''); + + return prefix + maskedContent + padding; +} + +export class OpenAIProviderImportDialogComponent extends Container implements Focusable { + focused = false; + + private readonly providerIdInput = new Input(); + private readonly baseUrlInput = new Input(); + private readonly apiKeyInput = new Input(); + private readonly onDone: (result: OpenAIProviderImportResult) => void; + private activeField: FieldId = 'providerId'; + private done = false; + private hint: Hint = 'none'; + + constructor( + onDone: (result: OpenAIProviderImportResult) => void, + defaults: Partial = {}, + ) { + super(); + this.onDone = onDone; + if (defaults.providerId !== undefined) this.providerIdInput.setValue(defaults.providerId); + if (defaults.baseUrl !== undefined) this.baseUrlInput.setValue(defaults.baseUrl); + if (defaults.apiKey !== undefined) this.apiKeyInput.setValue(defaults.apiKey); + + this.providerIdInput.onSubmit = () => { + this.focusField('baseUrl'); + }; + this.baseUrlInput.onSubmit = () => { + this.focusField('apiKey'); + }; + this.apiKeyInput.onSubmit = () => { + this.handleSubmit(); + }; + } + + 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(matchesKey(data, Key.shift('tab')) ? -1 : 1); + return; + } + if (matchesKey(data, Key.down)) { + this.toggleField(1); + return; + } + if (matchesKey(data, Key.up)) { + this.toggleField(-1); + return; + } + + if (this.hint !== 'none') this.hint = 'none'; + + if (this.activeField === 'providerId') { + this.providerIdInput.handleInput(data); + } else if (this.activeField === 'baseUrl') { + this.baseUrlInput.handleInput(data); + } else { + this.apiKeyInput.handleInput(data); + } + } + + override invalidate(): void { + super.invalidate(); + this.providerIdInput.invalidate(); + this.baseUrlInput.invalidate(); + this.apiKeyInput.invalidate(); + } + + override render(width: number): string[] { + const dialogActive = this.focused && !this.done; + this.providerIdInput.focused = dialogActive && this.activeField === 'providerId'; + this.baseUrlInput.focused = dialogActive && this.activeField === 'baseUrl'; + this.apiKeyInput.focused = dialogActive && this.activeField === 'apiKey'; + + const safeWidth = Math.max(0, width); + if (safeWidth <= 0) return ['']; + const innerWidth = Math.max(1, safeWidth - 4); + const pad = ' '; + + const border = (s: string): string => currentTheme.fg('primary', s); + const titleStyled = currentTheme.boldFg( + 'textStrong', + this.providerIdInput.getValue().trim().length > 0 ? TITLE_EDIT : TITLE_ADD, + ); + const subtitleStyled = currentTheme.fg('textDim', this.subtitleText()); + const footerStyled = currentTheme.fg( + 'textDim', + this.activeField === 'apiKey' ? FOOTER_LAST : FOOTER_NOT_LAST, + ); + + const providerLabelLine = this.labelLine('Provider ID', 'providerId', innerWidth); + const baseUrlLabelLine = this.labelLine('Base URL', 'baseUrl', innerWidth); + const apiKeyLabelLine = this.labelLine('API Key', 'apiKey', innerWidth); + const providerInputLine = this.providerIdInput.render(innerWidth)[0] ?? '> '; + const baseUrlInputLine = this.baseUrlInput.render(innerWidth)[0] ?? '> '; + const rawApiKeyInputLine = this.apiKeyInput.render(innerWidth)[0] ?? '> '; + const apiKeyInputLine = maskInputLine(rawApiKeyInputLine); + + const contentLines: string[] = [ + truncateToWidth(titleStyled, innerWidth, '...'), + '', + truncateToWidth(subtitleStyled, innerWidth, '...'), + '', + providerLabelLine, + providerInputLine, + '', + baseUrlLabelLine, + baseUrlInputLine, + '', + apiKeyLabelLine, + apiKeyInputLine, + '', + truncateToWidth(footerStyled, innerWidth, '...'), + ]; + + if (safeWidth < 4) { + return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, '...'))]; + } + + 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.map((line) => truncateToWidth(line, safeWidth, '...')); + } + + private labelLine(label: string, field: FieldId, width: number): string { + const styled = + this.activeField === field + ? currentTheme.boldFg('accent', label) + : currentTheme.fg('textDim', label); + return truncateToWidth(styled, width, '...'); + } + + private subtitleText(): string { + switch (this.hint) { + case 'provider-empty': + return SUBTITLE_PROVIDER_EMPTY; + case 'provider-invalid': + return SUBTITLE_PROVIDER_INVALID; + case 'base-url-empty': + return SUBTITLE_BASE_URL_EMPTY; + case 'api-key-empty': + return SUBTITLE_API_KEY_EMPTY; + case 'none': + return SUBTITLE_DEFAULT; + } + } + + private toggleField(delta: 1 | -1): void { + const fields: readonly FieldId[] = ['providerId', 'baseUrl', 'apiKey']; + const current = fields.indexOf(this.activeField); + const next = (current + delta + fields.length) % fields.length; + this.focusField(fields[next]!); + } + + private focusField(field: FieldId): void { + this.hint = 'none'; + this.activeField = field; + } + + private handleSubmit(): void { + if (this.done) return; + + const providerId = this.providerIdInput.getValue().trim(); + const baseUrl = this.baseUrlInput.getValue().trim(); + const apiKey = this.apiKeyInput.getValue().trim().replace(/^Bearer\s+/i, ''); + + if (providerId.length === 0) { + this.hint = 'provider-empty'; + this.activeField = 'providerId'; + return; + } + if (!PROVIDER_ID_RE.test(providerId)) { + this.hint = 'provider-invalid'; + this.activeField = 'providerId'; + return; + } + if (baseUrl.length === 0) { + this.hint = 'base-url-empty'; + this.activeField = 'baseUrl'; + return; + } + if (apiKey.length === 0) { + this.hint = 'api-key-empty'; + this.activeField = 'apiKey'; + return; + } + + this.done = true; + this.onDone({ kind: 'ok', value: { providerId, baseUrl, apiKey } }); + } + + private cancel(): void { + if (this.done) return; + this.done = true; + this.onDone({ kind: 'cancel' }); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts b/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts index 7ae0da03d2..2baa21c4cf 100644 --- a/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts +++ b/apps/kimi-code/src/tui/components/dialogs/provider-manager.ts @@ -13,7 +13,7 @@ * Keyboard: * - ↑ / ↓ move highlight * - ← / → · PgUp/PgDn page - * - Enter on `[ Add New Platform ]` → `onAdd()` + * - Enter edit selected provider, or add on `[ Add New Platform ]` * - D delete with inline `[y/N]` confirmation * on a source row → `onDeleteSource(providerIds)` * on `[ Add New Platform ]` → ignored @@ -61,6 +61,8 @@ export interface ProviderManagerOptions { /** Provider id of the currently active model. */ readonly activeProviderId?: string; readonly onAdd: () => void; + /** Edit a single provider/source row. Multi-provider registry rows are not editable here. */ + readonly onEditProvider: (providerId: string) => 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. */ @@ -91,7 +93,7 @@ type Row = SourceRow | AddRow; const ADD_ROW_LABEL = '[ Add New Platform ]'; const PAGE_SIZE = 8; -const HEADER_HINT = '↑↓ navigate · D delete · Esc cancel'; +const HEADER_HINT = '↑↓ navigate · Enter edit/add · D delete · Esc cancel'; // Narrows a `ProviderConfig` blob to a `CustomRegistrySource` payload. // Mirrors `readCustomRegistrySource` in `kimi-tui.ts`. We can't import @@ -308,6 +310,9 @@ export class ProviderManagerComponent extends Container implements Focusable { const selected = rows[this.selectedIndex]; if (selected?.kind === 'add') { this.opts.onAdd(); + } else if (selected?.kind === 'source' && selected.providerIds.length === 1) { + const providerId = selected.providerIds[0]; + if (providerId !== undefined) this.opts.onEditProvider(providerId); } return; } diff --git a/apps/kimi-code/test/tui/components/dialogs/provider-manager.test.ts b/apps/kimi-code/test/tui/components/dialogs/provider-manager.test.ts index 6596cf705b..a9b7e72e0a 100644 --- a/apps/kimi-code/test/tui/components/dialogs/provider-manager.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/provider-manager.test.ts @@ -25,6 +25,7 @@ function makeComponent(overrides: Partial = {}): Provide return new ProviderManagerComponent({ providers: {} as Record, onAdd: vi.fn(), + onEditProvider: vi.fn(), onDeleteSource: vi.fn(), onClose: vi.fn(), ...overrides, diff --git a/docs/en/configuration/providers.md b/docs/en/configuration/providers.md index de42925885..a30d9909d5 100644 --- a/docs/en/configuration/providers.md +++ b/docs/en/configuration/providers.md @@ -27,10 +27,11 @@ The manager displays providers as a list of entries grouped by source. Navigatio - ↑/↓ to move the cursor, ←/→ to page - `d` to delete the current provider (with `[y/N]` confirmation) -- Press Enter on the `[ Add New Platform ]` row to add a new provider +- Press Enter on a single-provider row to edit it, or on the `[ Add New Platform ]` row to add a new provider -Two paths when adding: +Three paths when adding: +- **OpenAI-compatible provider**: enter a provider ID, base URL, and API key; Kimi Code fetches `${baseUrl}/models`, creates model aliases automatically, and then opens `/model` so you can select one - **Known third-party provider**: fetches the model catalog from [models.dev](https://models.dev/), select a provider → enter an API key → select a default model - **Custom registry (api.json)**: paste a custom registry URL and Bearer token; the CLI automatically creates the `providers` / `models` entries. On later startup, providers from the same registry URL are refreshed together, so upstream provider additions, removals, and model metadata changes are synced. diff --git a/docs/zh/configuration/providers.md b/docs/zh/configuration/providers.md index b84351e9ef..01513d1b34 100644 --- a/docs/zh/configuration/providers.md +++ b/docs/zh/configuration/providers.md @@ -27,10 +27,11 @@ Kimi Code CLI 支持同时接入多家 LLM 平台——用 Kimi Code 托管服 - ↑/↓ 移动光标,←/→ 翻页 - `d` 键删除当前供应商(有 `[y/N]` 确认) -- 在 `[ Add New Platform ]` 行按 Enter 添加新供应商 +- 在单个供应商行按 Enter 编辑该供应商,或在 `[ Add New Platform ]` 行按 Enter 添加新供应商 -添加时有两条路径: +添加时有三条路径: +- **OpenAI-compatible provider**:输入供应商 ID、base URL 和 API 密钥;Kimi Code 会拉取 `${baseUrl}/models`,自动创建模型 alias,然后打开 `/model` 供你选择 - **Known third-party provider**:从 [models.dev](https://models.dev/) 拉取模型目录,选供应商 → 输入 API 密钥 → 选默认模型 - **Custom registry (api.json)**:粘贴自定义 registry 地址和 Bearer token,CLI 自动创建 `providers` / `models` 条目。后续启动时,同一个 registry 地址下的供应商会一起刷新,因此上游新增、删除供应商以及模型元数据变化都会同步。