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
This commit is contained in:
haozhe.yang 2026-06-02 17:33:13 +08:00
parent 0d074f1bae
commit c604ee71be
5 changed files with 117 additions and 15 deletions

View file

@ -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.

View file

@ -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<string, { id: string; name: string; api: string; type: string; models: Record<string, unknown> }>;
let entries: Awaited<ReturnType<typeof fetchCustomRegistry>>;
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<typeof applyCustomRegistryProvider>[0],
entry as Parameters<typeof applyCustomRegistryProvider>[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,

View file

@ -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<string, CustomRegistryProviderEntry>,
source: CustomRegistrySource,
): void {
for (const entry of Object.values(entries)) {
if (entry.id in config.providers) {
removeCustomRegistryProvider(config, entry.id);
}
applyCustomRegistryProvider(config, entry, source);
}
}

View file

@ -99,6 +99,7 @@ export type {
} from './open-platform';
export {
applyCustomRegistryEntries,
applyCustomRegistryProvider,
capabilitiesFromCustomEntry,
CustomRegistryApiError,

View file

@ -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<string, CustomRegistryProviderEntry> = {
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([]);