From c604ee71be73b75b6e22689bd7180423f4709bc4 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 2 Jun 2026 17:33:13 +0800 Subject: [PATCH] fix(oauth): fix multi-provider custom registry import losing providers - Add `applyCustomRegistryEntries` helper to apply all entries in-memory before a single write\n- Update CLI Add Platform flow to use it, replacing interleaved in-memory mutations with removeProvider RPCs\n- Add regression tests for repeated imports and provider field refreshing --- .../fix-custom-registry-multi-provider.md | 6 ++ apps/kimi-code/src/tui/commands/provider.ts | 25 +++---- packages/oauth/src/custom-registry.ts | 25 +++++++ packages/oauth/src/index.ts | 1 + packages/oauth/test/custom-registry.test.ts | 75 +++++++++++++++++++ 5 files changed, 117 insertions(+), 15 deletions(-) create mode 100644 .changeset/fix-custom-registry-multi-provider.md diff --git a/.changeset/fix-custom-registry-multi-provider.md b/.changeset/fix-custom-registry-multi-provider.md new file mode 100644 index 000000000..0d66c32c2 --- /dev/null +++ b/.changeset/fix-custom-registry-multi-provider.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code-oauth": patch +"@moonshot-ai/kimi-code": patch +--- + +Fix loss of providers when importing a multi-provider custom registry. diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts index 7690a805a..15b5b705f 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -1,7 +1,8 @@ import { - applyCustomRegistryProvider, + applyCustomRegistryEntries, fetchCustomRegistry, type CustomRegistrySource, + type ManagedKimiConfigShape, } from '@moonshot-ai/kimi-code-oauth'; import { applyCatalogProvider, @@ -268,7 +269,7 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise apiKey: value.apiKey, }; - let entries: Record }>; + let entries: Awaited>; try { entries = await fetchCustomRegistry(source); } catch (err) { @@ -276,20 +277,14 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise return false; } - const addedProviderIds: string[] = []; + const addedProviderIds = Object.values(entries).map((entry) => entry.id); 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); - } + const config = await host.harness.getConfig(); + applyCustomRegistryEntries( + config as unknown as ManagedKimiConfigShape, + entries, + source, + ); await host.harness.setConfig({ providers: config.providers, models: config.models, diff --git a/packages/oauth/src/custom-registry.ts b/packages/oauth/src/custom-registry.ts index 565fea4f9..b444cdcce 100644 --- a/packages/oauth/src/custom-registry.ts +++ b/packages/oauth/src/custom-registry.ts @@ -349,3 +349,28 @@ export function removeCustomRegistryProvider( config['defaultProvider'] = undefined; } } + +/** + * Applies every entry from a single api.json import in memory. Mirrors the + * "remove if present, then apply" sequence the Add Platform flow used to do + * via the `removeProvider` RPC, but stays purely in-memory so callers can + * persist the whole batch with a single write at the end. + * + * Bug fixed: previously the caller interleaved in-memory `applyCustomRegistry- + * Provider` with the disk-writing `removeProvider` RPC inside a loop. Each + * RPC re-read disk and returned a fresh config object, discarding entries that + * had already been merged in-memory from earlier iterations. Re-importing a + * multi-provider api.json silently lost N-1 of N providers. + */ +export function applyCustomRegistryEntries( + config: ManagedKimiConfigShape, + entries: Record, + source: CustomRegistrySource, +): void { + for (const entry of Object.values(entries)) { + if (entry.id in config.providers) { + removeCustomRegistryProvider(config, entry.id); + } + applyCustomRegistryProvider(config, entry, source); + } +} diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 45df8a78e..4aeda80e3 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -99,6 +99,7 @@ export type { } from './open-platform'; export { + applyCustomRegistryEntries, applyCustomRegistryProvider, capabilitiesFromCustomEntry, CustomRegistryApiError, diff --git a/packages/oauth/test/custom-registry.test.ts b/packages/oauth/test/custom-registry.test.ts index a4d6047e2..8256e7268 100644 --- a/packages/oauth/test/custom-registry.test.ts +++ b/packages/oauth/test/custom-registry.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { + applyCustomRegistryEntries, applyCustomRegistryProvider, CUSTOM_REGISTRY_DEFAULT_CAPABILITIES, CUSTOM_REGISTRY_DEFAULT_MAX_CONTEXT, @@ -382,6 +383,80 @@ describe('removeCustomRegistryProvider', () => { }); }); +describe('applyCustomRegistryEntries', () => { + // Regression: re-importing the same multi-provider api.json used to lose all + // but the last provider because the caller mixed in-memory mutations with the + // `harness.removeProvider` RPC (which read/wrote disk inside the loop and + // returned a fresh config object, discarding prior iterations' additions). + // The pure in-memory helper must keep every entry across repeated imports. + it('keeps every provider when the same multi-provider source is applied twice', () => { + const source: CustomRegistrySource = { + kind: 'apiJson', + url: 'https://registry.example.test/v1/models/api.json', + apiKey: 'sk-token', + }; + const entries: Record = { + a: { id: 'a', name: 'A', api: 'https://a.test/v1', type: 'openai', models: { 'm1': { id: 'm1' } } }, + b: { id: 'b', name: 'B', api: 'https://b.test/v1', type: 'openai', models: { 'm1': { id: 'm1' } } }, + c: { id: 'c', name: 'C', api: 'https://c.test/v1', type: 'openai', models: { 'm1': { id: 'm1' } } }, + }; + + const config: ManagedKimiConfigShape = { providers: {} }; + applyCustomRegistryEntries(config, entries, source); + applyCustomRegistryEntries(config, entries, source); + + expect(Object.keys(config.providers).sort()).toEqual(['a', 'b', 'c']); + expect(config.models?.['a/m1']).toBeDefined(); + expect(config.models?.['b/m1']).toBeDefined(); + expect(config.models?.['c/m1']).toBeDefined(); + }); + + it('refreshes provider fields, drops stale aliases, and clears defaultModel that no longer exists', () => { + const source: CustomRegistrySource = { + kind: 'apiJson', + url: 'https://registry.example.test/api.json', + apiKey: 'sk-new', + }; + const config: ManagedKimiConfigShape = { + providers: { + x: { type: 'openai', baseUrl: 'https://x-old.test/v1', apiKey: 'sk-old' }, + }, + models: { + 'x/old-model': { provider: 'x', model: 'old-model', maxContextSize: 1000 }, + 'other/keep': { provider: 'other', model: 'keep', maxContextSize: 1000 }, + }, + defaultModel: 'x/old-model', + }; + + applyCustomRegistryEntries( + config, + { + x: { + id: 'x', + name: 'X', + api: 'https://x-new.test/v1', + type: 'openai', + models: { 'new-model': { id: 'new-model' } }, + }, + }, + source, + ); + + expect(config.providers['x']).toMatchObject({ + type: 'openai', + baseUrl: 'https://x-new.test/v1', + apiKey: 'sk-new', + }); + expect(config.models?.['x/old-model']).toBeUndefined(); + expect(config.models?.['x/new-model']).toBeDefined(); + expect(config.models?.['other/keep']).toBeDefined(); + // defaultModel pointed at the now-removed alias, so it must be cleared + // (matches the old harness.removeProvider semantics that the caller relied + // on before the refactor). + expect(config.defaultModel).toBeUndefined(); + }); +}); + describe('capabilitiesFromCustomEntry', () => { it('returns an empty array when no rich fields are present', () => { expect(capabilitiesFromCustomEntry({ id: 'm' })).toEqual([]);