fix(secondary-model): stop rewriting the section when providers refresh or are removed (#3284)

* fix(secondary-model): stop rewriting the section when providers refresh or are removed

Provider refresh, provider deletion/rename, catalog/registry import,
OAuth logout, and SDK removeProvider used to cascade into the user's
[secondary_model] block: pool entries were silently pruned, and the
whole section was deleted when its effective default dangled. The
cascade ran from a cache-refresh path (including an unattended 6h
scheduler), so upstream model-list changes could irreversibly destroy
hand-written configuration without any notice.

Config is user intent; the catalog is an availability snapshot. Stop
rewriting the section on every provider/models writer. An entry whose
model no longer resolves fails pool validation on the next session
create with a message naming the offending alias, which is the same
fail-fast contract hand-written typos already had.

* chore(sdk): add changeset for the removed secondary-model cascade export

* Delete .changeset/sdk-remove-secondary-model-cascade.md

Signed-off-by: 7Sageer <sag77r@hotmail.com>

* Delete .changeset/secondary-model-no-silent-rewrite.md

Signed-off-by: 7Sageer <sag77r@hotmail.com>

---------

Signed-off-by: 7Sageer <sag77r@hotmail.com>
This commit is contained in:
7Sageer 2026-08-27 16:50:42 +08:00 committed by GitHub
parent 7066950653
commit bd5e32f683
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 78 additions and 219 deletions

View file

@ -6,7 +6,6 @@ import {
} from '@moonshot-ai/kimi-code-oauth';
import {
applyCatalogProvider,
cascadeSubagentModelPool,
catalogProviderModels,
CatalogFetchError,
DEFAULT_CATALOG_URL,
@ -236,10 +235,6 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise<void> {
// entered. The model selector that follows is just a convenience to pick the
// default model; ESC leaves the provider in place without a default selection.
const existingConfig = await host.harness.getConfig();
const poolSnapshot =
existingConfig.providers[providerId] !== undefined
? existingConfig.secondaryModel
: undefined;
if (existingConfig.providers[providerId] !== undefined) {
await host.harness.removeProvider(providerId);
}
@ -260,16 +255,6 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise<void> {
models: config.models,
});
// removeProvider cascaded the subagent pool against a model table where
// every `${providerId}/...` alias was absent; restore the entries that
// survived the re-add (aliases the catalog genuinely dropped stay dropped).
if (poolSnapshot !== undefined) {
const restored = cascadeSubagentModelPool(poolSnapshot, config.models ?? {});
if (restored !== null) {
await host.harness.setConfig({ secondaryModel: restored ?? poolSnapshot });
}
}
await host.authFlow.refreshConfigAfterLogin();
host.track('connect', { provider: providerId, method: 'catalog' });
host.showStatus(`Provider added: ${entry.name ?? providerId}`);

View file

@ -220,6 +220,8 @@ Constraints between the fields:
- `default_effort` is section-wide: every spawn binds it regardless of the chosen pool entry (or the forced model). For per-entry efforts, leave it unset and use model variants (see below).
- `primary` is a reserved alias (see below) and cannot be a pool key.
Pool aliases reference the current `[models]` table: if a provider is later deleted or logged out, or its refreshed model list no longer contains an alias, session startup fails with a configuration error naming the broken alias — fix or remove the entry to recover. The `[secondary_model]` section itself is never rewritten automatically.
In the interactive TUI, the [`/secondary-model`](../reference/slash-commands.md) command (alias `/subagent-model`) opens a model selector: the choice is written to `default_model` (when a models table exists and the picked alias is not in it, an entry with an empty description is added), and newly spawned subagents pick up the new default immediately — no session restart needed.
A configured pool — an explicit `models` table or a lone `default_model` — enables model selection: the `Agent` / `AgentSwarm` tools gain a `model` parameter, and the tool description lists the pool (the default marked `[default]`) so the main agent can choose per spawn. Pool keys can only reference configured [`[models]`](#models) entries — the `kimi-code/*` aliases below are provisioned by `/login`:

View file

@ -220,6 +220,8 @@ default_model = "kimi-code/kimi-for-coding-highspeed"
- `default_effort` 是节级设置:无论派生绑定到池中哪个条目(或 force 固定的模型)都生效。想按条目区分档位时不要设置它,改用下文的模型「变体」。
- `primary` 是保留字(含义见下文),不能作为池中 key。
池别名引用的是 `[models]` 表的当前内容:如果之后删除供应商、登出账号,或其刷新后的模型列表不再包含某个别名,会话启动时会报出指明失效别名的配置错误,修正或移除对应条目即可恢复。系统不会自动改写 `[secondary_model]` 节。
在交互式 TUI 中,也可以用 [`/secondary-model`](../reference/slash-commands.md) 命令(别名 `/subagent-model`)打开模型选择器:选择后写入 `default_model`(已有 models 表而所选别名不在其中时,会一并补一条空描述条目),之后派生的 subagent 立即按新默认值绑定,无需重启会话。
配置了模型池(显式的 `models` 表或隐式的单条目池)即启用模型选择:`Agent` / `AgentSwarm` 工具会获得 `model` 参数,工具描述中列出模型池(默认模型标注 `[default]`main agent 可按次派生选择模型。池 key 只能引用已配置的 [`[models]`](#models) 条目——下面的 `kimi-code/*` 别名由 `/login` 自动提供:

View file

@ -29,11 +29,6 @@ import {
PROVIDERS_SECTION,
THINKING_SECTION,
} from './configSection';
import {
SECONDARY_MODEL_SECTION,
cascadeSubagentModelPool,
type SecondaryModelConfig,
} from '#/session/subagent/configSection';
import {
IProviderDiscoveryService,
ModelCatalogChanged,
@ -219,16 +214,6 @@ export class ProviderDiscoveryService implements IProviderDiscoveryService {
if ('thinking' in patch) {
sections[THINKING_SECTION] = restoreDefault ? exclusion.thinking : patch.thinking;
}
const nextModels = sections[MODELS_SECTION] as Record<string, ModelRecord> | undefined;
if (nextModels !== undefined) {
const cascadedPool = cascadeSubagentModelPool(
this.config.inspect<SecondaryModelConfig>(SECONDARY_MODEL_SECTION).userValue,
nextModels,
);
if (cascadedPool !== undefined) {
sections[SECONDARY_MODEL_SECTION] = cascadedPool ?? undefined;
}
}
await this.config.replaceSections(sections);
return {
providers:

View file

@ -19,11 +19,6 @@ import { modelsDevProviderModels, resolveModelsDevImport } from './modelsDev';
import { DEFAULT_MODEL_SECTION, MODELS_SECTION, PROVIDERS_SECTION } from './configSection';
import { ModelsDevImportErrors } from './errors';
import { IKosongConfigService } from './kosongConfig';
import {
SECONDARY_MODEL_SECTION,
cascadeSubagentModelPool,
type SecondaryModelConfig,
} from '#/session/subagent/configSection';
import {
IModelsDevImportService,
PROVIDER_ID_PATTERN,
@ -104,19 +99,6 @@ export class ModelsDevImportService implements IModelsDevImportService {
return this.config;
}
private async cascadePool(
config: IConfigService,
nextModels: Record<string, unknown>,
): Promise<void> {
const cascaded = cascadeSubagentModelPool(
config.inspect<SecondaryModelConfig>(SECONDARY_MODEL_SECTION).userValue,
nextModels,
);
if (cascaded !== undefined) {
await config.replace(SECONDARY_MODEL_SECTION, cascaded);
}
}
private async doImportModelsDevProvider(
options: ImportModelsDevProviderOptions,
): Promise<ImportModelsDevProviderResult> {
@ -185,7 +167,6 @@ export class ModelsDevImportService implements IModelsDevImportService {
nextModels[`${targetId}/${model.id}`] = modelsDevModelToRecord(targetId, model);
}
await config.replace(MODELS_SECTION, nextModels);
await this.cascadePool(config, nextModels);
const firstModel = models[0];
if (firstModel !== undefined) {
@ -274,7 +255,6 @@ export class ModelsDevImportService implements IModelsDevImportService {
}
await config.replace(PROVIDERS_SECTION, applied.providers as ProvidersSection);
await config.replace(MODELS_SECTION, (applied.models ?? {}) as ModelsSection);
await this.cascadePool(config, applied.models ?? {});
const firstEntry = Object.values(entries)[0];
const firstModelKey = firstEntry === undefined ? undefined : Object.keys(firstEntry.models)[0];

View file

@ -184,40 +184,6 @@ export function assertValidSubagentModelConfig(
if (pool !== undefined) assertValidSubagentModelPool(pool, modelCatalog);
}
export function cascadeSubagentModelPool(
section: SecondaryModelConfig | undefined,
survivingModels: Record<string, unknown>,
renamedAliases: ReadonlyMap<string, string> = new Map(),
): SecondaryModelConfig | null | undefined {
if (section === undefined) return undefined;
const remap = (alias: string): string => renamedAliases.get(alias) ?? alias;
const nextDefault = section.defaultModel === undefined ? undefined : remap(section.defaultModel);
const nextLegacyDefault = section.model === undefined ? undefined : remap(section.model);
const effectiveDefault = nextDefault ?? nextLegacyDefault;
if (effectiveDefault !== undefined && !(effectiveDefault in survivingModels)) return null;
let changed = nextDefault !== section.defaultModel || nextLegacyDefault !== section.model;
let nextPool: Record<string, string> | undefined;
if (section.models !== undefined) {
nextPool = {};
for (const [alias, description] of Object.entries(section.models)) {
const key = remap(alias);
if (!(key in survivingModels)) {
changed = true;
continue;
}
if (key !== alias) changed = true;
nextPool[key] = description;
}
if (Object.keys(nextPool).length === 0) {
nextPool = undefined;
changed = true;
}
}
if (!changed) return undefined;
return { ...section, defaultModel: nextDefault, model: nextLegacyDefault, models: nextPool };
}
export function resolveSubagentBinding(
config: IConfigService,
flags: IFlagService,

View file

@ -423,7 +423,7 @@ describe('refreshProviderModels write behavior', () => {
}
});
it('clears the subagent model pool when a refresh drops its default alias', async () => {
it('leaves the subagent model pool untouched when a refresh drops its default alias', async () => {
const baseUrl = 'https://api.managed.example.test/coding/v1';
vi.stubEnv('KIMI_CODE_BASE_URL', baseUrl);
const fetchMock = vi.fn(
@ -455,13 +455,16 @@ describe('refreshProviderModels write behavior', () => {
expect(result.changed).toEqual([
{ provider_id: 'my-kimi', provider_name: 'my-kimi', added: 1, removed: 1 },
]);
expect(config.get('secondaryModel')).toBeUndefined();
expect(config.get('secondaryModel')).toEqual({
defaultModel: 'my-kimi/kimi-k2',
models: { 'my-kimi/kimi-k2': 'fast and cheap' },
});
} finally {
host.dispose();
}
});
it('filters pool entries a refresh dropped while keeping a surviving default', async () => {
it('leaves the whole pool untouched even when a refresh drops a non-default entry', async () => {
const baseUrl = 'https://api.managed.example.test/coding/v1';
vi.stubEnv('KIMI_CODE_BASE_URL', baseUrl);
const fetchMock = vi.fn(
@ -497,7 +500,7 @@ describe('refreshProviderModels write behavior', () => {
]);
expect(config.get('secondaryModel')).toEqual({
defaultModel: 's1',
models: { s1: 'static fallback' },
models: { s1: 'static fallback', 'my-kimi/kimi-k2': 'managed' },
});
} finally {
host.dispose();

View file

@ -236,7 +236,7 @@ describe('IModelsDevImportService', () => {
expect(config.get('defaultModel')).toBe('k2');
});
it('filters pool entries a catalog import drops, keeping a surviving default', async () => {
it('leaves the pool untouched when a catalog import drops an entry', async () => {
setModelsDevUpstreamForTest({ fetchImpl: fetchJson(CATALOG) });
const { config, imports } = createHost({
providers: { openai: { type: 'openai', apiKey: 'sk-old' } },
@ -254,11 +254,11 @@ describe('IModelsDevImportService', () => {
expect(config.get('secondaryModel')).toEqual({
defaultModel: 'k2',
models: { k2: 'fast' },
models: { k2: 'fast', 'openai/gpt-4o': 'smart' },
});
});
it('clears the pool when a catalog import orphans its default', async () => {
it('leaves the pool untouched when a catalog import orphans its default', async () => {
setModelsDevUpstreamForTest({ fetchImpl: fetchJson(CATALOG) });
const { config, imports } = createHost({
providers: { openai: { type: 'openai', apiKey: 'sk-old' } },
@ -270,10 +270,10 @@ describe('IModelsDevImportService', () => {
await imports.importModelsDevProvider({ catalogId: 'openai' });
expect(config.get('secondaryModel')).toBeUndefined();
expect(config.get('secondaryModel')).toEqual({ defaultModel: 'openai/gpt-4o' });
});
it('cascades the pool on custom-registry imports too', async () => {
it('leaves the pool untouched on custom-registry imports too', async () => {
setModelsDevUpstreamForTest({ fetchImpl: fetchJson(REGISTRY_DOC) });
const { config, imports } = createHost({
providers: { 'acme-gpt': { type: 'openai', apiKey: 'sk-old' } },
@ -285,7 +285,7 @@ describe('IModelsDevImportService', () => {
await imports.importCustomRegistry({ url: REGISTRY_URL });
expect(config.get('secondaryModel')).toBeUndefined();
expect(config.get('secondaryModel')).toEqual({ defaultModel: 'acme-gpt/gpt-old' });
});
it('seeds default_model from the first imported model only when none is configured', async () => {

View file

@ -21,11 +21,6 @@ import {
MODELS_SECTION,
PROVIDERS_SECTION,
} from '@moonshot-ai/agent-core-v2/app/kosongConfig/configSection';
import {
SECONDARY_MODEL_SECTION,
cascadeSubagentModelPool,
type SecondaryModelConfig,
} from '@moonshot-ai/agent-core-v2/session/subagent/configSection';
import { z } from 'zod';
import { errEnvelope, okEnvelope } from '../envelope';
@ -444,24 +439,6 @@ export function registerModelCatalogRoutes(app: ModelCatalogRouteHost, core: Sco
}
}
const renamedAliases = new Map<string, string>();
if (newId !== provider_id) {
for (const oldAlias of previousAliasIds) {
const bare = models[oldAlias]?.model;
const renamed = bare === undefined ? undefined : `${newId}/${bare}`;
if (renamed !== undefined && nextModels[renamed] !== undefined) {
renamedAliases.set(oldAlias, renamed);
}
}
}
const secondaryModel = config.inspect<SecondaryModelConfig>(
SECONDARY_MODEL_SECTION,
).userValue;
const cascadedPool = cascadeSubagentModelPool(secondaryModel, nextModels, renamedAliases);
if (cascadedPool !== undefined) {
await config.replace(SECONDARY_MODEL_SECTION, cascadedPool);
}
const saved = await core.accessor.get(IModelCatalog).getProvider(newId);
reply.send(okEnvelope({ provider: saved }, req.id));
});
@ -657,13 +634,6 @@ export function registerModelCatalogRoutes(app: ModelCatalogRouteHost, core: Sco
if (Object.keys(restModels).length !== Object.keys(models).length) {
await config.replace(MODELS_SECTION, restModels);
}
const secondaryModel = config.inspect<SecondaryModelConfig>(
SECONDARY_MODEL_SECTION,
).userValue;
const cascadedPool = cascadeSubagentModelPool(secondaryModel, restModels);
if (cascadedPool !== undefined) {
await config.replace(SECONDARY_MODEL_SECTION, cascadedPool);
}
(reply as unknown as StatusReply).code(204).send();
});
},

View file

@ -446,7 +446,7 @@ describe('server-v2 /api/v1 provider write endpoints', () => {
});
});
it('filters secondary_model pool entries whose provider was deleted', async () => {
it('leaves the secondary_model pool untouched when a provider is deleted', async () => {
await boot(POOL_TOML);
const { status } = await deleteJson<unknown>('/api/v1/providers/openai');
expect(status).toBe(204);
@ -454,17 +454,20 @@ describe('server-v2 /api/v1 provider write endpoints', () => {
const onDisk = await readConfigToml();
expect(onDisk['secondary_model']).toEqual({
default_model: 'k2',
models: { k2: 'fast' },
models: { k2: 'fast', gpt4o: 'smart' },
});
});
it('drops the secondary_model section when its default dangles after deletion', async () => {
it('keeps the secondary_model section even when its default dangles after deletion', async () => {
await boot(POOL_DANGLING_DEFAULT_TOML);
const { status } = await deleteJson<unknown>('/api/v1/providers/openai');
expect(status).toBe(204);
const onDisk = await readConfigToml();
expect(onDisk['secondary_model']).toBeUndefined();
expect(onDisk['secondary_model']).toEqual({
default_model: 'gpt4o',
models: { k2: 'fast', gpt4o: 'smart' },
});
});
it('round-trips a created provider: delete removes every trace from config.toml', async () => {
@ -710,7 +713,7 @@ describe('server-v2 /api/v1 provider write endpoints', () => {
expect(onDisk['default_model']).toBe('gpt4o');
});
it('repoints secondary_model pool entries on provider rename', async () => {
it('leaves secondary_model pool entries alone on provider rename', async () => {
await boot(POOL_TOML);
const { status } = await putJson<unknown>('/api/v1/providers/openai', {
type: 'openai',
@ -722,11 +725,11 @@ describe('server-v2 /api/v1 provider write endpoints', () => {
const onDisk = await readConfigToml();
expect(onDisk['secondary_model']).toEqual({
default_model: 'k2',
models: { k2: 'fast', 'my-openai/gpt-4o': 'smart' },
models: { k2: 'fast', gpt4o: 'smart' },
});
});
it('filters secondary_model pool entries dropped by a provider edit', async () => {
it('leaves secondary_model pool entries alone on provider edit', async () => {
await boot(POOL_TOML);
const { status } = await putJson<unknown>('/api/v1/providers/openai', REPLACE_BODY);
expect(status).toBe(200);
@ -734,17 +737,20 @@ describe('server-v2 /api/v1 provider write endpoints', () => {
const onDisk = await readConfigToml();
expect(onDisk['secondary_model']).toEqual({
default_model: 'k2',
models: { k2: 'fast' },
models: { k2: 'fast', gpt4o: 'smart' },
});
});
it('drops the secondary_model section when a provider edit orphans its default', async () => {
it('keeps the secondary_model section even when a provider edit orphans its default', async () => {
await boot(POOL_DANGLING_DEFAULT_TOML);
const { status } = await putJson<unknown>('/api/v1/providers/openai', REPLACE_BODY);
expect(status).toBe(200);
const onDisk = await readConfigToml();
expect(onDisk['secondary_model']).toBeUndefined();
expect(onDisk['secondary_model']).toEqual({
default_model: 'gpt4o',
models: { k2: 'fast', gpt4o: 'smart' },
});
});
it('rejects a rename to an existing provider id with 40921', async () => {

View file

@ -82,10 +82,6 @@ export { SECONDARY_DERIVED_MODEL_ALIAS } from '@moonshot-ai/agent-core';
// caller's own model, so hosts must not offer a user alias named `primary`
// as the subagent default model.
export { PRIMARY_SUBAGENT_MODEL_CHOICE } from '@moonshot-ai/agent-core-v2/session/subagent/configSection';
// Pool cascade for writes that rebuild the `[models]` table: hosts staging a
// provider overwrite (remove-then-re-add) use it to restore the still-valid
// pool entries against the final alias set.
export { cascadeSubagentModelPool } from '@moonshot-ai/agent-core-v2/session/subagent/configSection';
// Process-wide HTTP proxy bootstrap — installed once at CLI startup so all
// outbound fetch honors HTTP_PROXY / HTTPS_PROXY / NO_PROXY.

View file

@ -691,30 +691,30 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
/**
* v1's removal cascades: the provider entry, every model pointing at it,
* the default pointers when they dangle, and the `[secondary_model]`
* subagent pool entries (the section itself when its default dangles).
* The engine's own `kosong.removeProvider` only clears the
* default-provider pointer, so the full v1 cascade is computed from the
* user-layer values (see `planProviderRemoval`) and persisted as ONE
* atomic multi-section replace the same single-write shape as v1's
* `removeKimiProvider`, so a process exit can never leave the file in a
* halfway-cascaded state.
* and the default pointers when they dangle. The engine's own
* `kosong.removeProvider` only clears the default-provider pointer, so the
* full v1 cascade is computed from the user-layer values (see
* `planProviderRemoval`) and persisted as ONE atomic multi-section
* replace the same single-write shape as v1's `removeKimiProvider`, so a
* process exit can never leave the file in a halfway-cascaded state. The
* `[secondary_model]` section is left alone on purpose: an entry whose
* model no longer resolves fails pool validation on the next session
* create, surfacing a named error instead of silently rewriting the
* user's configuration.
*/
override async removeProvider(providerId: string): Promise<KimiConfig> {
await this.configReady;
const [providers, models, defaultModel, defaultProvider, secondaryModel] = await Promise.all([
const [providers, models, defaultModel, defaultProvider] = await Promise.all([
this.klient.global.config.inspect<Record<string, unknown>>('providers'),
this.klient.global.config.inspect<Record<string, Record<string, unknown>>>('models'),
this.klient.global.config.inspect<string>('defaultModel'),
this.klient.global.config.inspect<string>('defaultProvider'),
this.klient.global.config.inspect<Record<string, unknown>>('secondaryModel'),
]);
const plan = planProviderRemoval({
providers: providers.userValue,
models: models.userValue,
defaultModel: defaultModel.userValue,
defaultProvider: defaultProvider.userValue,
secondaryModel: secondaryModel.userValue,
providerId,
});
const sections: Record<string, unknown> = {
@ -727,11 +727,6 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
if (plan.clearDefaultProvider) {
sections['defaultProvider'] = undefined;
}
if (plan.secondaryModel !== undefined) {
// `null` clears the whole section; a replacement object folds the
// filtered pool into the same atomic write.
sections['secondaryModel'] = plan.secondaryModel ?? undefined;
}
await this.klient.global.config.replaceSections({ sections });
return this.getConfig();
}

View file

@ -89,14 +89,6 @@ export interface ProviderRemovalPlan {
readonly models: Record<string, unknown>;
readonly clearDefaultModel: boolean;
readonly clearDefaultProvider: boolean;
/**
* Cascade for the `[secondary_model]` subagent pool / legacy recipe:
* `undefined` = unchanged, `null` = drop the whole section (its effective
* default dangles, so the section can no longer validate), otherwise the
* replacement section with pool entries pointing at removed models
* filtered out.
*/
readonly secondaryModel: Record<string, unknown> | null | undefined;
}
/**
@ -107,18 +99,16 @@ export interface ProviderRemovalPlan {
* cascade through the config facade. Inputs are the USER-layer values
* (`inspect().userValue`), matching v1's disk-config write base.
*
* The `[secondary_model]` section cascades too: pool entries that name a
* removed model alias are filtered out, and when the effective default
* (`defaultModel`, or the legacy recipe's `model` fallback) dangles the
* whole section is dropped a surviving `[secondary_model.models]` table
* without its default would fail pool validation on every session create.
* The `[secondary_model]` section is deliberately left untouched: it is the
* user's own configuration, and an entry whose model no longer resolves
* fails pool validation on the next session create with a message naming
* the offending alias a loud error beats a silent rewrite.
*/
export function planProviderRemoval(input: {
readonly providers: Record<string, unknown> | undefined;
readonly models: Record<string, Record<string, unknown>> | undefined;
readonly defaultModel: string | undefined;
readonly defaultProvider: string | undefined;
readonly secondaryModel?: Record<string, unknown>;
readonly providerId: string;
}): ProviderRemovalPlan {
const providers = { ...input.providers };
@ -139,36 +129,9 @@ export function planProviderRemoval(input: {
models,
clearDefaultModel: removedDefault,
clearDefaultProvider: input.defaultProvider === input.providerId,
secondaryModel: planSecondaryModelCascade(input.secondaryModel, models),
};
}
/**
* Cascade the provider removal into the `[secondary_model]` section against
* the surviving model-alias table. See `ProviderRemovalPlan.secondaryModel`
* for the tri-state result.
*/
function planSecondaryModelCascade(
secondaryModel: Record<string, unknown> | undefined,
survivingModels: Record<string, unknown>,
): Record<string, unknown> | null | undefined {
if (secondaryModel === undefined) return undefined;
const defaultAlias = secondaryModel['defaultModel'] ?? secondaryModel['model'];
if (typeof defaultAlias === 'string' && !(defaultAlias in survivingModels)) {
return null;
}
const pool = secondaryModel['models'];
if (pool === undefined || typeof pool !== 'object' || pool === null) {
return undefined;
}
const entries = Object.entries(pool as Record<string, unknown>);
const surviving = entries.filter(([alias]) => alias in survivingModels);
if (surviving.length === entries.length) return undefined;
return { ...secondaryModel, models: Object.fromEntries(surviving) };
}
/**
* Apply the v1 remove-provider cascade to a whole `KimiConfig` in memory (no
* persistence): drop the provider entry, every model pointing at it, and the
@ -183,7 +146,6 @@ export function removeProviderFromConfig(config: KimiConfig, providerId: string)
models: config.models as Record<string, Record<string, unknown>> | undefined,
defaultModel: config.defaultModel,
defaultProvider: config.defaultProvider,
secondaryModel: config.secondaryModel as Record<string, unknown> | undefined,
providerId,
});
return {
@ -192,9 +154,5 @@ export function removeProviderFromConfig(config: KimiConfig, providerId: string)
models: plan.models as KimiConfig['models'],
defaultModel: plan.clearDefaultModel ? undefined : config.defaultModel,
defaultProvider: plan.clearDefaultProvider ? undefined : config.defaultProvider,
secondaryModel:
plan.secondaryModel === null
? undefined
: ((plan.secondaryModel ?? config.secondaryModel) as KimiConfig['secondaryModel']),
};
}

View file

@ -886,7 +886,7 @@ key = "${titleOAuthRef.key}"
}
});
it('cascades removeProvider into the secondary_model pool', async () => {
it('leaves the secondary_model pool untouched on removeProvider', async () => {
const { harness } = await makeHarness();
try {
await harness.setConfig({
@ -904,24 +904,30 @@ key = "${titleOAuthRef.key}"
},
});
// Pool entries naming a removed model alias are filtered out; the
// surviving default keeps the section valid.
const filtered = await harness.removeProvider('b');
expect(filtered.secondaryModel).toEqual({
// Pool entries naming a removed model alias are kept as written; an
// unresolvable entry fails pool validation on the next session create.
const kept = await harness.removeProvider('b');
expect(kept.secondaryModel).toEqual({
defaultModel: 'a/m1',
models: { 'a/m1': 'fast' },
models: { 'a/m1': 'fast', 'b/m1': 'smart' },
});
// When the pool's default dangles the whole section is dropped — a
// leftover models table without its default would fail pool validation
// on every session create.
// Even a dangling default leaves the whole section in place on disk.
// (`setConfig` merges per domain, so the pool table is still the one
// written above; the default now points at the provider being removed.)
await harness.setConfig({
secondaryModel: { defaultModel: 'a/m1', models: { 'a/m1': 'fast' } },
secondaryModel: { defaultModel: 'a/m1' },
});
const cleared = await harness.removeProvider('a');
expect(cleared.secondaryModel).toBeUndefined();
expect(cleared.secondaryModel).toEqual({
defaultModel: 'a/m1',
models: { 'a/m1': 'fast', 'b/m1': 'smart' },
});
const reread = await harness.getConfig({ reload: true });
expect(reread.secondaryModel).toBeUndefined();
expect(reread.secondaryModel).toEqual({
defaultModel: 'a/m1',
models: { 'a/m1': 'fast', 'b/m1': 'smart' },
});
} finally {
await harness.close();
}
@ -1360,7 +1366,7 @@ describe('removeProviderFromConfig', () => {
expect(next.defaultProvider).toBe('a');
});
it('filters secondary_model pool entries whose model alias was removed', () => {
it('leaves secondary_model pool entries alone when their model alias was removed', () => {
const config = {
providers: { a: { type: 'openai' }, b: { type: 'openai' } },
models: {
@ -1377,11 +1383,11 @@ describe('removeProviderFromConfig', () => {
expect(next.secondaryModel).toEqual({
defaultModel: 'a/m1',
models: { 'a/m1': 'fast' },
models: { 'a/m1': 'fast', 'b/m1': 'smart' },
});
});
it('drops the secondary_model section when its default model dangles', () => {
it('keeps the secondary_model section even when its default model dangles', () => {
const config = {
providers: { a: { type: 'openai' }, b: { type: 'openai' } },
models: {
@ -1394,15 +1400,20 @@ describe('removeProviderFromConfig', () => {
},
} as unknown as KimiConfig;
expect(removeProviderFromConfig(config, 'b').secondaryModel).toBeUndefined();
expect(removeProviderFromConfig(config, 'b').secondaryModel).toEqual({
defaultModel: 'b/m1',
models: { 'a/m1': 'fast', 'b/m1': 'smart' },
});
// The legacy recipe's `model` key acts as the default fallback and
// cascades the same way.
// The legacy recipe's `model` key is left alone the same way.
const legacy = {
...config,
secondaryModel: { model: 'b/m1', default_effort: 'low' },
} as unknown as KimiConfig;
expect(removeProviderFromConfig(legacy, 'b').secondaryModel).toBeUndefined();
expect(removeProviderFromConfig(legacy, 'b').secondaryModel).toEqual({
model: 'b/m1',
default_effort: 'low',
});
});
it('leaves the secondary_model section untouched when nothing dangles', () => {