From 1760045f1a2f99dbacada6dc01fb6afb3e80daf6 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Fri, 12 Jun 2026 10:35:46 +0800 Subject: [PATCH 1/2] feat(providers): sync custom registry providers on startup refresh - group registry providers by URL and retry available API keys\n- automatically add new providers and remove disappeared ones\n- coalesce duplicate source URLs to avoid false config-change reports\n- clear defaultThinking when default model is removed\n- update docs and add tests for registry sync scenarios --- .changeset/sync-registry-providers.md | 5 + apps/kimi-code/src/cli/sub/provider.ts | 5 +- .../src/tui/utils/refresh-providers.ts | 135 ++++++++- .../test/tui/utils/refresh-providers.test.ts | 283 ++++++++++++++++++ docs/en/configuration/providers.md | 2 +- docs/en/reference/kimi-command.md | 2 +- docs/zh/configuration/providers.md | 2 +- docs/zh/reference/kimi-command.md | 2 +- packages/oauth/src/custom-registry.ts | 7 +- 9 files changed, 418 insertions(+), 25 deletions(-) create mode 100644 .changeset/sync-registry-providers.md diff --git a/.changeset/sync-registry-providers.md b/.changeset/sync-registry-providers.md new file mode 100644 index 000000000..b0908a7ca --- /dev/null +++ b/.changeset/sync-registry-providers.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Sync custom registry provider additions, removals, and rotated registry keys during startup refresh. diff --git a/apps/kimi-code/src/cli/sub/provider.ts b/apps/kimi-code/src/cli/sub/provider.ts index b712891b2..cd30c1957 100644 --- a/apps/kimi-code/src/cli/sub/provider.ts +++ b/apps/kimi-code/src/cli/sub/provider.ts @@ -7,8 +7,9 @@ * * `add` writes the same `source = { kind: 'apiJson', url, apiKey }` blob the * TUI does; the next launch's `refreshAllProviderModels` - * (apps/kimi-code/src/tui/utils/refresh-providers.ts) groups by `{url, apiKey}` - * and re-fetches the model list, so periodic refresh is automatic. + * (apps/kimi-code/src/tui/utils/refresh-providers.ts) groups by URL, retries + * available API-key candidates, and re-fetches the model list, so periodic + * refresh is automatic. */ import { diff --git a/apps/kimi-code/src/tui/utils/refresh-providers.ts b/apps/kimi-code/src/tui/utils/refresh-providers.ts index aa8cd7577..bdda7aa40 100644 --- a/apps/kimi-code/src/tui/utils/refresh-providers.ts +++ b/apps/kimi-code/src/tui/utils/refresh-providers.ts @@ -10,6 +10,7 @@ import { filterModelsByPrefix, getOpenPlatformById, isOpenPlatformId, + removeCustomRegistryProvider, resolveKimiCodeRuntimeAuth, type CustomRegistrySource, type ManagedKimiConfigShape, @@ -42,7 +43,7 @@ export interface RefreshResult { function readCustomRegistrySource(provider: ProviderConfig): CustomRegistrySource | undefined { const source = provider.source; if (typeof source !== 'object' || source === null) return undefined; - const candidate = source as Record; + const candidate = source; if (candidate['kind'] !== 'apiJson') return undefined; const url = candidate['url']; const apiKey = candidate['apiKey']; @@ -51,6 +52,36 @@ function readCustomRegistrySource(provider: ProviderConfig): CustomRegistrySourc return { kind: 'apiJson', url, apiKey }; } +function customRegistrySourceKey(source: CustomRegistrySource): string { + return JSON.stringify([source.url]); +} + +function customRegistrySourceCredentialKey(source: CustomRegistrySource): string { + return JSON.stringify([source.url, source.apiKey]); +} + +async function fetchCustomRegistryFromSources( + sources: readonly CustomRegistrySource[], +): Promise<{ + readonly entries: Awaited>; + readonly source: CustomRegistrySource; +}> { + let lastError: unknown; + for (const source of sources) { + try { + return { + entries: await fetchCustomRegistry(source), + source, + }; + } catch (error) { + lastError = error; + } + } + if (lastError instanceof Error) throw lastError; + if (typeof lastError === 'string') throw new Error(lastError); + throw new Error('No custom registry sources configured.'); +} + function asManaged(config: KimiConfig): ManagedKimiConfigShape { return config as unknown as ManagedKimiConfigShape; } @@ -143,6 +174,14 @@ function providerModelsEqual( ); } +function providerConfigSnapshot(config: KimiConfig, providerId: string): string { + return JSON.stringify(config.providers[providerId] ?? null); +} + +function providerConfigEqual(config: KimiConfig, nextConfig: KimiConfig, providerId: string): boolean { + return providerConfigSnapshot(config, providerId) === providerConfigSnapshot(nextConfig, providerId); +} + function providerRefreshAliasKeys( config: KimiConfig, nextConfig: KimiConfig, @@ -199,6 +238,15 @@ function clampDanglingDefault(config: KimiConfig): void { } } +function clearDefaultThinkingWhenDefaultRemoved( + config: KimiConfig, + previousDefaultModel: string | undefined, +): void { + if (previousDefaultModel !== undefined && config.defaultModel === undefined) { + config.defaultThinking = undefined; + } +} + function pickDefaultModel(config: KimiConfig, providerId: string, models: Array<{ id: string }>): string { const firstModel = models[0]; if (firstModel === undefined) return ''; @@ -263,6 +311,7 @@ export async function refreshAllProviderModels(host: RefreshProviderHost): Promi ); restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); clampDanglingDefault(next); + clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel); if (providerModelsEqual(config, next, KIMI_CODE_PROVIDER_NAME, refreshedAliasKeys)) { unchanged.push(KIMI_CODE_PROVIDER_NAME); @@ -332,6 +381,7 @@ export async function refreshAllProviderModels(host: RefreshProviderHost): Promi restoreProviderAliases(next, preserveUserProviderAliases(config, providerId, refreshedAliasKeys)); restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); clampDanglingDefault(next); + clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel); if (providerModelsEqual(config, next, providerId, refreshedAliasKeys)) { unchanged.push(providerId); @@ -363,26 +413,42 @@ export async function refreshAllProviderModels(host: RefreshProviderHost): Promi } // ------------------------------------------------------------------------- - // 3. Custom Registry providers (grouped by {url, apiKey}) + // 3. Custom Registry providers (grouped by URL, with API-key candidates) // ------------------------------------------------------------------------- - const customSources = new Map(); + const customSources = new Map< + string, + { + readonly sources: CustomRegistrySource[]; + readonly sourceKeys: Set; + readonly providerIds: string[]; + } + >(); for (const [providerId, providerConfig] of Object.entries(config.providers)) { if (providerId === KIMI_CODE_PROVIDER_NAME) continue; if (isOpenPlatformId(providerId)) continue; const source = readCustomRegistrySource(providerConfig); if (source === undefined) continue; - const key = `${source.url}${source.apiKey}`; + const key = customRegistrySourceKey(source); + const sourceKey = customRegistrySourceCredentialKey(source); const entry = customSources.get(key); if (entry !== undefined) { + if (!entry.sourceKeys.has(sourceKey)) { + entry.sources.push(source); + entry.sourceKeys.add(sourceKey); + } entry.providerIds.push(providerId); } else { - customSources.set(key, { source, providerIds: [providerId] }); + customSources.set(key, { + sources: [source], + sourceKeys: new Set([sourceKey]), + providerIds: [providerId], + }); } } - for (const { source, providerIds } of customSources.values()) { + for (const { sources, providerIds } of customSources.values()) { try { - const entries = await fetchCustomRegistry(source); + const { entries, source } = await fetchCustomRegistryFromSources(sources); // Build the whole batch on one clone so that several changed providers // from the same source do not overwrite each other's aliases, and so the // config we compare is exactly the config we persist. @@ -393,17 +459,47 @@ export async function refreshAllProviderModels(host: RefreshProviderHost): Promi readonly added: number; readonly removed: number; }> = []; + const providersToRemoveBeforeSet = new Set(); + let hasUnreportedConfigChange = false; + const remoteEntries = Object.values(entries); + const remoteEntriesByProviderId = new Map( + remoteEntries.map((entry) => [entry.id, entry]), + ); + const providerIdsToSync = new Set(providerIds); + for (const entry of remoteEntries) providerIdsToSync.add(entry.id); + + for (const providerId of providerIdsToSync) { + const entry = remoteEntriesByProviderId.get(providerId); + if (entry === undefined) { + const oldIds = collectModelIdsForAliases(config, providerAliasKeys(config, providerId)); + removeCustomRegistryProvider(asManaged(next), providerId); + changedProviders.push({ + providerId, + providerName: providerId, + added: 0, + removed: oldIds.size, + }); + providersToRemoveBeforeSet.add(providerId); + continue; + } - for (const providerId of providerIds) { - const entry = entries[providerId]; - if (entry === undefined) continue; - + const existed = config.providers[providerId] !== undefined; applyCustomRegistryProvider(asManaged(next), entry, source); const refreshedAliasKeys = providerRefreshAliasKeys(config, next, providerId, `${providerId}/`); - restoreProviderAliases(next, preserveUserProviderAliases(config, providerId, refreshedAliasKeys)); + if (existed) { + restoreProviderAliases(next, preserveUserProviderAliases(config, providerId, refreshedAliasKeys)); + } - if (providerModelsEqual(config, next, providerId, refreshedAliasKeys)) { + if ( + existed && + providerModelsEqual(config, next, providerId, refreshedAliasKeys) && + providerConfigEqual(config, next, providerId) + ) { + unchanged.push(providerId); + } else if (existed && providerModelsEqual(config, next, providerId, refreshedAliasKeys)) { unchanged.push(providerId); + providersToRemoveBeforeSet.add(providerId); + hasUnreportedConfigChange = true; } else { const { added, removed } = computeChanges( collectModelIdsForAliases(config, refreshedAliasKeys), @@ -415,13 +511,15 @@ export async function refreshAllProviderModels(host: RefreshProviderHost): Promi added, removed, }); + if (existed) providersToRemoveBeforeSet.add(providerId); } } - if (changedProviders.length > 0) { + if (changedProviders.length > 0 || hasUnreportedConfigChange) { restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); clampDanglingDefault(next); - for (const { providerId } of changedProviders) { + clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel); + for (const providerId of providersToRemoveBeforeSet) { await host.removeProvider(providerId); } config = await host.setConfig({ @@ -431,7 +529,12 @@ export async function refreshAllProviderModels(host: RefreshProviderHost): Promi defaultThinking: next.defaultThinking, }); for (const change of changedProviders) { - changed.push(change); + changed.push({ + providerId: change.providerId, + providerName: change.providerName, + added: change.added, + removed: change.removed, + }); } } } catch (error) { diff --git a/apps/kimi-code/test/tui/utils/refresh-providers.test.ts b/apps/kimi-code/test/tui/utils/refresh-providers.test.ts index d2b0d778b..17749a61a 100644 --- a/apps/kimi-code/test/tui/utils/refresh-providers.test.ts +++ b/apps/kimi-code/test/tui/utils/refresh-providers.test.ts @@ -261,6 +261,289 @@ describe('refreshAllProviderModels', () => { expect(host.current().models?.[userAlias]).toEqual(userAliasModel); }); + it('adds custom-registry providers that appear under an existing source URL', async () => { + const registryUrl = 'https://registry.example.test/v1/models/api.json'; + const apiKey = 'sk-test-token'; + const source = { kind: 'apiJson', url: registryUrl, apiKey }; + const host = makeRefreshHost({ + providers: { + a: { + type: 'openai', + baseUrl: 'https://a.example.test/v1', + apiKey, + source, + }, + }, + models: { + 'a/m1': { + provider: 'a', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'm1', + }, + }, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn(async (input, init) => { + expect(fetchInputUrl(input)).toBe(registryUrl); + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer sk-test-token'); + return new Response( + JSON.stringify({ + a: { + id: 'a', + name: 'Provider A', + api: 'https://a.example.test/v1', + type: 'openai', + models: { m1: { id: 'm1' } }, + }, + b: { + id: 'b', + name: 'Provider B', + api: 'https://b.example.test/v1', + type: 'openai', + models: { m1: { id: 'm1' } }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result.failed).toEqual([]); + expect(result.unchanged).toEqual(['a']); + expect(result.changed).toEqual([ + { + providerId: 'b', + providerName: 'Provider B', + added: 1, + removed: 0, + }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(host.removeProvider).not.toHaveBeenCalled(); + expect(host.setConfig).toHaveBeenCalledTimes(1); + expect(Object.keys(host.current().providers).toSorted()).toEqual(['a', 'b']); + expect(host.current().providers['b']).toMatchObject({ + type: 'openai', + baseUrl: 'https://b.example.test/v1', + apiKey, + source, + }); + expect(host.current().models?.['b/m1']).toEqual({ + provider: 'b', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'm1', + }); + }); + + it('removes custom-registry providers that disappear from an existing source URL', async () => { + const registryUrl = 'https://registry.example.test/v1/models/api.json'; + const apiKey = 'sk-test-token'; + const source = { kind: 'apiJson', url: registryUrl, apiKey }; + const host = makeRefreshHost({ + providers: { + a: { + type: 'openai', + baseUrl: 'https://a.example.test/v1', + apiKey, + source, + }, + b: { + type: 'openai', + baseUrl: 'https://b.example.test/v1', + apiKey, + source, + }, + }, + models: { + 'a/m1': { + provider: 'a', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'm1', + }, + 'b/m1': { + provider: 'b', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'm1', + }, + 'my-b': { + provider: 'b', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'My B', + }, + }, + defaultModel: 'my-b', + defaultThinking: true, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn(async (input, init) => { + expect(fetchInputUrl(input)).toBe(registryUrl); + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer sk-test-token'); + return new Response( + JSON.stringify({ + a: { + id: 'a', + name: 'Provider A', + api: 'https://a.example.test/v1', + type: 'openai', + models: { m1: { id: 'm1' } }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result.failed).toEqual([]); + expect(result.unchanged).toEqual(['a']); + expect(result.changed).toEqual([ + { + providerId: 'b', + providerName: 'b', + added: 0, + removed: 1, + }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(host.removeProvider).toHaveBeenCalledWith('b'); + expect(host.setConfig).toHaveBeenCalledTimes(1); + expect(Object.keys(host.current().providers)).toEqual(['a']); + expect(host.current().models?.['a/m1']).toBeDefined(); + expect(host.current().models?.['b/m1']).toBeUndefined(); + expect(host.current().models?.['my-b']).toBeUndefined(); + expect(host.current().defaultModel).toBeUndefined(); + expect(host.current().defaultThinking).toBeUndefined(); + }); + + it('coalesces duplicate custom-registry source URLs without reporting config-only changes', async () => { + const registryUrl = 'https://registry.example.test/v1/models/api.json'; + const oldSource = { kind: 'apiJson', url: registryUrl, apiKey: 'sk-old-token' }; + const newSource = { kind: 'apiJson', url: registryUrl, apiKey: 'sk-new-token' }; + const host = makeRefreshHost({ + providers: { + a: { + type: 'openai', + baseUrl: 'https://a.example.test/v1', + apiKey: 'sk-old-token', + source: oldSource, + }, + b: { + type: 'openai', + baseUrl: 'https://b.example.test/v1', + apiKey: 'sk-new-token', + source: newSource, + }, + }, + models: { + 'a/m1': { + provider: 'a', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'm1', + }, + 'b/m1': { + provider: 'b', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'm1', + }, + }, + telemetry: true, + } as unknown as KimiConfig); + + const fetchMock = vi.fn(async (input, init) => { + expect(fetchInputUrl(input)).toBe(registryUrl); + const authorization = new Headers(init?.headers).get('authorization'); + if (authorization === 'Bearer sk-old-token') { + return new Response(JSON.stringify({ message: 'expired token' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + expect(authorization).toBe('Bearer sk-new-token'); + return new Response( + JSON.stringify({ + a: { + id: 'a', + name: 'Provider A', + api: 'https://a.example.test/v1', + type: 'openai', + models: { m1: { id: 'm1' } }, + }, + b: { + id: 'b', + name: 'Provider B', + api: 'https://b.example.test/v1', + type: 'openai', + models: { m1: { id: 'm1' }, m2: { id: 'm2' } }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result.failed).toEqual([]); + expect(result.unchanged).toEqual(['a']); + expect(result.changed).toEqual([ + { + providerId: 'b', + providerName: 'Provider B', + added: 1, + removed: 0, + }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(host.removeProvider).toHaveBeenCalledWith('a'); + expect(host.removeProvider).toHaveBeenCalledWith('b'); + expect(host.setConfig).toHaveBeenCalledTimes(1); + expect(host.current().providers['a']?.source).toEqual(newSource); + expect(host.current().providers['b']?.source).toEqual(newSource); + expect(host.current().providers['a']?.apiKey).toBe('sk-new-token'); + expect(host.current().providers['b']?.apiKey).toBe('sk-new-token'); + expect(host.current().models?.['b/m2']).toEqual({ + provider: 'b', + model: 'm2', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'm2', + }); + }); + it('ignores user-defined aliases when custom-registry metadata is unchanged', async () => { const registryUrl = 'https://registry.example.test/v1/models/api.json'; const providerId = 'example_chat-completions'; diff --git a/docs/en/configuration/providers.md b/docs/en/configuration/providers.md index f57c7730e..8fed5c4e1 100644 --- a/docs/en/configuration/providers.md +++ b/docs/en/configuration/providers.md @@ -32,7 +32,7 @@ The manager displays providers as a list of entries grouped by source. Navigatio Two paths when adding: - **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 +- **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. ::: warning Kimi Code OAuth managed accounts logged in via `/login` do not appear in `/provider`. Use `/login` and `/logout` to manage them. diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index 5b96a9496..a0623445b 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -226,7 +226,7 @@ Five actions are available: #### `kimi provider add ` -Bulk-import all providers from a custom registry (`api.json`). The command fetches the registry, creates a `[providers.]` and `[models.]` entry for each item, and writes `source` metadata so the TUI refreshes the model list automatically on next startup. +Bulk-import all providers from a custom registry (`api.json`). The command fetches the registry, creates a `[providers.]` and `[models.]` entry for each item, and writes `source` metadata so the TUI refreshes providers and models from the same registry URL automatically on next startup. | Parameter / Option | Description | | --- | --- | diff --git a/docs/zh/configuration/providers.md b/docs/zh/configuration/providers.md index 939b56181..41aae2736 100644 --- a/docs/zh/configuration/providers.md +++ b/docs/zh/configuration/providers.md @@ -32,7 +32,7 @@ Kimi Code CLI 支持同时接入多家 LLM 平台——用 Kimi Code 托管服 添加时有两条路径: - **Known third-party provider**:从 [models.dev](https://models.dev/) 拉取模型目录,选供应商 → 输入 API 密钥 → 选默认模型 -- **Custom registry (api.json)**:粘贴自定义 registry 地址和 Bearer token,CLI 自动创建 `providers` / `models` 条目 +- **Custom registry (api.json)**:粘贴自定义 registry 地址和 Bearer token,CLI 自动创建 `providers` / `models` 条目。后续启动时,同一个 registry 地址下的供应商会一起刷新,因此上游新增、删除供应商以及模型元数据变化都会同步。 ::: warning 通过 `/login` 登录的 Kimi Code OAuth 托管账号不会在 `/provider` 里显示,请用 `/login` 和 `/logout` 管理。 diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 67d259f30..9e8c9180b 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -226,7 +226,7 @@ kimi provider [options] #### `kimi provider add ` -从自定义 registry(`api.json`)批量导入所有供应商。命令会拉取 registry,为每个条目创建 `[providers.]` 和 `[models.]`,并写入 `source` 元数据,使 TUI 下次启动时自动刷新模型列表。 +从自定义 registry(`api.json`)批量导入所有供应商。命令会拉取 registry,为每个条目创建 `[providers.]` 和 `[models.]`,并写入 `source` 元数据,使 TUI 下次启动时自动刷新同一 registry 地址下的供应商和模型。 | 参数 / 选项 | 说明 | | --- | --- | diff --git a/packages/oauth/src/custom-registry.ts b/packages/oauth/src/custom-registry.ts index cad5fd9f2..0c5d720f7 100644 --- a/packages/oauth/src/custom-registry.ts +++ b/packages/oauth/src/custom-registry.ts @@ -6,9 +6,10 @@ 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. + * URL may produce multiple providers (one per top-level entry in the api.json + * document). Refresh treats the URL as the stable registry identity and may try + * more than one API key when existing provider records drift during key + * rotation. */ export interface CustomRegistrySource { readonly kind: 'apiJson'; From 4335c7804b5d955576c6ebb6ebb46f205c8fe7e7 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Fri, 12 Jun 2026 16:50:19 +0800 Subject: [PATCH 2/2] fix(tui): only show provider refresh status for added models Skip removed / metadata-only provider updates when reporting model list changes.\n\n add: test to enforce the behavior. --- apps/kimi-code/src/tui/kimi-tui.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 0551b2a64..87c0b4cb2 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -475,10 +475,10 @@ export class KimiTUI { 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(' · ') + '.'); + if (c.added <= 0) continue; + this.showStatus( + `${c.providerName} · +${String(c.added)} model${c.added > 1 ? 's' : ''}.`, + ); } for (const f of result.failed) { this.showStatus(`Skipped refreshing ${f.provider}: ${f.reason}`, 'warning');