Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/olive-camels-refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix models in use by a running session failing with "not configured in config.toml" when another Kimi Code process refreshes the provider model list at the same time.
61 changes: 53 additions & 8 deletions apps/kimi-code/src/tui/controllers/auth-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,11 @@ export class AuthFlowController {
* `replaceSections`), the orchestrator's two-phase contract (removeProvider
* then setConfig) is absorbed the same way the v2 engine's own refresh path
* does it: the removal is staged in memory only, and the following
* setConfig persists the complete records in a single write — so a process
* exit mid-refresh can never leave config.toml in a "provider removed, not
* yet restored" state. The v1 harness keeps the legacy host (two
* whole-document writes, each atomic on its own).
* setConfig merges the sparse patch onto a fresh read and persists
* everything in a single write — so a process exit mid-refresh can never
* leave config.toml in a "provider removed, not yet restored" state, and
* concurrent writes from other processes survive. The v1 harness keeps the
* legacy host (two whole-document writes, each atomic on its own).
*/
private buildRefreshHost(): RefreshProviderHost {
const { host } = this;
Expand All @@ -239,6 +240,7 @@ export class AuthFlowController {
};
}
let staged: KimiConfig | undefined;
const pendingRemovals = new Set<string>();
const requireStaged = (): KimiConfig => {
if (staged === undefined) {
throw new Error('refresh host: getConfig must be called before writes');
Expand All @@ -251,17 +253,60 @@ export class AuthFlowController {
return staged;
},
removeProvider: (id) => {
pendingRemovals.add(id);
staged = removeProviderFromConfig(requireStaged(), id);
return Promise.resolve(staged);
},
setConfig: async (patch) => {
// The orchestrator always passes complete records (built from a full
// clone), so the Partial-shaped patch is a full KimiConfig overlay.
staged = { ...requireStaged(), ...patch } as KimiConfig;
// The patch is sparse — only the refreshed providers' entries and
// their owned aliases — so merge it onto a fresh read for unrelated
// keys to survive, and fold the staged removals into the same write.
const fresh = await host.harness.getConfig({ reload: true });
const sections: Record<string, unknown> = {};
if (pendingRemovals.size > 0 || patch.providers !== undefined) {
const providers: Record<string, unknown> = { ...fresh.providers };
for (const id of pendingRemovals) delete providers[id];
if (patch.providers !== undefined) Object.assign(providers, patch.providers);
sections['providers'] = providers;
}
if (pendingRemovals.size > 0 || patch.models !== undefined) {
const models: Record<string, unknown> = { ...fresh.models };
for (const [key, record] of Object.entries(models)) {
const owner =
typeof record === 'object' && record !== null
? (record as { provider?: string }).provider
: undefined;
if (owner !== undefined && pendingRemovals.has(owner)) delete models[key];
}
if (patch.models !== undefined) Object.assign(models, patch.models);
sections['models'] = models;
}
// Object.entries keeps keys whose value is `undefined`, so a cleared
// section (e.g. a dangling defaultModel) is expressed as a removal in
// the atomic write; sections absent from the patch stay untouched.
await host.harness.replaceConfigSections(Object.fromEntries(Object.entries(patch)));
for (const [key, value] of Object.entries(patch)) {
if (key === 'providers' || key === 'models') continue;
sections[key] = value;
}
if (pendingRemovals.size > 0 && !('defaultModel' in patch)) {
const defaultModel = fresh.defaultModel;
const owner =
defaultModel === undefined
? undefined
: (fresh.models?.[defaultModel] as { provider?: string } | undefined)?.provider;
if (owner !== undefined && pendingRemovals.has(owner)) {
sections['defaultModel'] = undefined;
}
}
if (pendingRemovals.size > 0 && !('defaultProvider' in patch)) {
const defaultProvider = fresh.defaultProvider;
if (defaultProvider !== undefined && pendingRemovals.has(defaultProvider)) {
sections['defaultProvider'] = undefined;
}
}
pendingRemovals.clear();
await host.harness.replaceConfigSections(sections);
staged = { ...fresh, ...sections } as KimiConfig;
return staged;
},
resolveOAuthToken,
Expand Down
13 changes: 12 additions & 1 deletion apps/kimi-code/test/tui/utils/refresh-providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,18 @@ function makeRefreshHost(initial: KimiConfig): {
return structuredClone(persisted);
});
const setConfig = vi.fn(async (patch: Partial<KimiConfig>) => {
persisted = { ...persisted, ...patch };
const next = { ...persisted };
if (patch.providers !== undefined) {
next.providers = { ...persisted.providers, ...patch.providers };
}
if (patch.models !== undefined) {
next.models = { ...persisted.models, ...patch.models };
}
for (const [key, value] of Object.entries(patch)) {
if (key === 'providers' || key === 'models') continue;
(next as Record<string, unknown>)[key] = value;
}
persisted = next;
return structuredClone(persisted);
});
return {
Expand Down
89 changes: 46 additions & 43 deletions packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { getProviderDefinition } from '#/kosong/provider/providerDefinition';

import {
DEFAULT_MODEL_SECTION,
DEFAULT_PROVIDER_SECTION,
MODELS_SECTION,
PROVIDERS_SECTION,
THINKING_SECTION,
Expand Down Expand Up @@ -142,10 +143,11 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService {
}

private buildRefreshHost(exclusion: StaticExclusion, userAgent: string): RefreshProviderHost {
const pendingRemovals = new Set<string>();
return {
getConfig: async () => this.readUserConfigShape(exclusion),
removeProvider: (providerId) => this.shapeWithoutProvider(providerId),
setConfig: (patch) => this.applyRefreshPatch(patch, exclusion),
removeProvider: (providerId) => this.queueProviderRemoval(pendingRemovals, providerId),
setConfig: (patch) => this.applyRefreshPatch(patch, pendingRemovals),
resolveOAuthToken: (providerName, oauthRef) => this.resolveOAuthToken(providerName, oauthRef),
userAgent,
};
Expand Down Expand Up @@ -173,7 +175,11 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService {
};
}

private shapeWithoutProvider(providerId: string): Promise<ManagedKimiConfigShape> {
private queueProviderRemoval(
pendingRemovals: Set<string>,
providerId: string,
): Promise<ManagedKimiConfigShape> {
pendingRemovals.add(providerId);
const current = this.readUserConfigShape();
const providers = current.providers as Record<string, ProviderConfig>;
const restProviders = Object.fromEntries(
Expand All @@ -192,57 +198,54 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService {

private async applyRefreshPatch(
patch: ManagedKimiConfigShape,
exclusion: StaticExclusion,
pendingRemovals: Set<string>,
): Promise<ManagedKimiConfigShape> {
const userProviders =
await this.config.reload();
const removals = [...pendingRemovals];
pendingRemovals.clear();
const removed = new Set(removals);
const providers =
this.config.inspect<Record<string, ProviderConfig>>(PROVIDERS_SECTION).userValue ?? {};
const userModels =
const models =
this.config.inspect<Record<string, ModelRecord>>(MODELS_SECTION).userValue ?? {};
const sections: Record<string, unknown> = {};
if (patch.providers !== undefined) {
sections[PROVIDERS_SECTION] = {
...exclusion.providers,
...patch.providers,
};
if (removals.length > 0 || patch.providers !== undefined) {
const nextProviders: Record<string, unknown> = Object.fromEntries(
Object.entries(providers).filter(([id]) => !removed.has(id)),
);
if (patch.providers !== undefined) Object.assign(nextProviders, patch.providers);
sections[PROVIDERS_SECTION] = nextProviders;
}
if (patch.models !== undefined) {
sections[MODELS_SECTION] = {
...exclusion.models,
...(patch.models as Record<string, ModelRecord>),
};
if (removals.length > 0 || patch.models !== undefined) {
const nextModels: Record<string, unknown> = Object.fromEntries(
Object.entries(models).filter(
([, record]) => record.provider === undefined || !removed.has(record.provider),
),
);
if (patch.models !== undefined) Object.assign(nextModels, patch.models);
sections[MODELS_SECTION] = nextModels;
}
const restoreDefault = exclusion.defaultModel !== undefined;
if ('defaultModel' in patch) {
sections[DEFAULT_MODEL_SECTION] = restoreDefault
? exclusion.defaultModel
: patch.defaultModel;
sections[DEFAULT_MODEL_SECTION] = patch.defaultModel;
} else if (removals.length > 0) {
const defaultModel = this.config.inspect<string>(DEFAULT_MODEL_SECTION).userValue;
if (defaultModel !== undefined && removed.has(models[defaultModel]?.provider ?? '')) {
sections[DEFAULT_MODEL_SECTION] = undefined;
}
}
if ('thinking' in patch) {
sections[THINKING_SECTION] = restoreDefault ? exclusion.thinking : patch.thinking;
sections[THINKING_SECTION] = patch.thinking;
}
if ('defaultProvider' in patch) {
sections[DEFAULT_PROVIDER_SECTION] = patch['defaultProvider'];
} else if (removals.length > 0) {
const defaultProvider = this.config.inspect<string>(DEFAULT_PROVIDER_SECTION).userValue;
if (defaultProvider !== undefined && removed.has(defaultProvider)) {
sections[DEFAULT_PROVIDER_SECTION] = undefined;
}
}
await this.config.replaceSections(sections);
return {
providers:
patch.providers !== undefined
? ({ ...exclusion.providers, ...patch.providers } as ManagedKimiConfigShape['providers'])
: (userProviders as ManagedKimiConfigShape['providers']),
models:
patch.models !== undefined
? ({ ...exclusion.models, ...patch.models } as ManagedKimiConfigShape['models'])
: (userModels as ManagedKimiConfigShape['models']),
defaultModel:
'defaultModel' in patch
? restoreDefault
? exclusion.defaultModel
: patch.defaultModel
: this.config.inspect<string>(DEFAULT_MODEL_SECTION).userValue,
thinking:
'thinking' in patch
? restoreDefault
? exclusion.thinking
: patch.thinking
: this.config.inspect<ManagedKimiConfigShape['thinking']>(THINKING_SECTION).userValue,
};
return this.readUserConfigShape();
}

private async resolveOAuthToken(
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-core-v2/src/kosong/model/catalogService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ export class ModelCatalog extends Disposable implements IModelCatalog {
}

notifyConfigChanged(): void {
this.cache.clear();
for (const id of this.cache.keys()) {
if (this.models.get(id) !== undefined) this.cache.delete(id);
Comment on lines +99 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scope retained catalog entries to active sessions

IModelCatalog is registered at app scope, so retaining every deleted cached alias is not limited to the running session that needs it. Once any session has resolved an alias, deleting that alias from config leaves this cache entry available indefinitely; a later session in the same daemon can explicitly bind the deleted alias and reuse its stale provider endpoint and authentication material instead of receiving MODEL_NOT_FOUND. Retention needs to be tied to active session usage rather than all previously cached entries.

Useful? React with 👍 / 👎.

}
}

get(id: string): Model {
Expand Down
86 changes: 35 additions & 51 deletions packages/agent-core-v2/test/app/kosongConfig/discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,69 +639,53 @@ describe('refreshProviderModels defaultModel self-heal', () => {
}
});

it('keeps a default model the user selected while the catalog fetch was in flight', async () => {
const twoModels = {
'kimi-code/kimi-k2': {
provider: KIMI_CODE_PROVIDER_NAME,
model: 'kimi-k2',
maxContextSize: 131072,
capabilities: ['thinking', 'tool_use'],
displayName: 'Kimi K2',
},
'kimi-code/kimi-k3': {
provider: KIMI_CODE_PROVIDER_NAME,
model: 'kimi-k3',
maxContextSize: 131072,
capabilities: ['thinking', 'tool_use'],
displayName: 'Kimi K3',
},
};
const { host, config, discovery, events } = await createHost(
it('keeps user writes that landed while the catalog fetch was in flight', async () => {
const { host, config, discovery, models } = await createHost(
{
providers: managedProviders,
models: twoModels,
models: managedModels,
},
stubOAuthService(stubTokenProvider(['access-token'])),
);
try {
vi.stubGlobal(
'fetch',
vi.fn(
async () => {
await config.set('defaultModel', 'kimi-code/kimi-k3');
return new Response(
JSON.stringify({
data: [
{
id: 'kimi-k2',
context_length: 131072,
supports_reasoning: true,
display_name: 'Kimi K2',
},
{
id: 'kimi-k3',
context_length: 131072,
supports_reasoning: true,
display_name: 'Kimi K3',
},
],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
},
),
vi.fn(async () => {
await config.set('defaultModel', 'kimi-code/kimi-k3');
await config.set('models', {
'other/keep': { provider: 'other', model: 'keep', maxContextSize: 1000 },
});
return new Response(
JSON.stringify({
data: [
{
id: 'kimi-k2',
context_length: 131072,
supports_reasoning: true,
display_name: 'Kimi K2',
},
{
id: 'kimi-k3',
context_length: 131072,
supports_reasoning: true,
display_name: 'Kimi K3',
},
],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}),
);
const replaceSections = vi.spyOn(config, 'replaceSections');
const result = await discovery.refreshProviderModels({ scope: 'all' });

expect(result).toEqual({
changed: [],
unchanged: [KIMI_CODE_PROVIDER_NAME],
failed: [],
});
expect(replaceSections).not.toHaveBeenCalled();
expect(events.published).toEqual([]);
expect(result.failed).toEqual([]);
expect(result.changed).toEqual([
{ provider_id: KIMI_CODE_PROVIDER_NAME, provider_name: 'Kimi Code', added: 1, removed: 0 },
]);
expect(config.get<string>('defaultModel')).toBe('kimi-code/kimi-k3');
const modelRecords = models.list();
expect(modelRecords['kimi-code/kimi-k3']).toBeDefined();
expect(modelRecords['other/keep']).toBeDefined();
} finally {
host.dispose();
}
Expand Down
6 changes: 5 additions & 1 deletion packages/agent-core-v2/test/kosong/model/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,7 @@ describe('ModelCatalog caching and config-event invalidation', () => {
}
});

it('drops the cache when a watched config section changes', async () => {
it('drops the cache on edits but keeps entries whose model was removed', async () => {
const { host, catalog, models, providers } = createHost(kimiSections);
try {
const before = catalog.get('k1');
Expand All @@ -528,6 +528,10 @@ describe('ModelCatalog caching and config-event invalidation', () => {

await providers.set('kimi', { type: 'kimi', apiKey: 'sk-2', baseUrl: 'https://other.example.test/v1' });
expect(catalog.get('k1').baseUrl).toBe('https://other.example.test/v1');

const updated = catalog.get('k1');
await models.delete('k1');
expect(catalog.get('k1')).toBe(updated);
} finally {
host.dispose();
}
Expand Down
Loading
Loading