fix(config): 供应商改名同步重键模型别名 + 删除时清理关联配置(v0.8.3-rc1)
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
修复「改名后模型别名保留旧前缀,僵尸条目删了又出现」这一类配置不一致。 - 改名时同步重键模型别名、全局默认模型、供应商记住的默认模型、子代理池条目 - 改名冲突(撞已存在供应商名 / 遗留裸别名)加 -2/-3 后缀,不再静默吞模型 - 修复复制供应商复用移动语义 rekey 导致源供应商模型被清空 - 加载时不再合并「供应商已不存在」的 SQLite 僵尸模型 - 删除供应商/模型时清理子代理池条目、池默认回落、default_model 置空、 SQLite 用量查询配置(不误删另一 agent,读取失败即跳过) - 防呆:复制供应商改为 dirty-only,放弃修改不再落盘 - 测试基建:统一 crate 级 test_state 锁,消除环境变量并发竞争 测试:前端 146(新增 16)、Rust 197(新增 4)
This commit is contained in:
1 parent
317562781c
commit
35788c2783
20 files changed
+4217
-3893
No files matched your search
+66
-34
@@ -20,6 +20,14 @@ import {
|
||||
type ProviderPreset,
|
||||
} from "./config/providerPresets";
|
||||
import { validateProviders } from "./lib/validation";
|
||||
import {
|
||||
copyModelsOnProviderDuplicate,
|
||||
rekeyModelsOnProviderRename,
|
||||
} from "./lib/providerRename";
|
||||
import {
|
||||
purgeSubagentPoolAliases,
|
||||
rekeySubagentPoolAliases,
|
||||
} from "./lib/referenceCleanup";
|
||||
import type { Agent, Model, Provider, UsageConfig } from "./types";
|
||||
|
||||
const AGENT_STORAGE_KEY = "kimi-switch-agent";
|
||||
@@ -153,9 +161,6 @@ export default function App() {
|
||||
// provider being switched to — only that one needs to be
|
||||
// complete; sibling providers being merely listed in the config
|
||||
// must not block the switch.
|
||||
// 3. Programmatic auto-saves (duplicate) call `save` directly with
|
||||
// no validation — duplicates inherit completeness from their
|
||||
// source.
|
||||
// Returns `true` when the config was actually persisted, `false` when
|
||||
// the user cancelled the validation confirm (callers can react to this
|
||||
// to roll back in-memory mutations made before the save).
|
||||
@@ -287,15 +292,30 @@ export default function App() {
|
||||
);
|
||||
if (oldEntry && oldEntry[0] !== provider.name) {
|
||||
delete providers[oldEntry[0]];
|
||||
const models = { ...cfg.models };
|
||||
for (const key of Object.keys(models)) {
|
||||
if (models[key].provider === editingProvider) {
|
||||
models[key] = { ...models[key], provider: provider.name };
|
||||
}
|
||||
// Re-key this provider's model aliases so the `<provider>/<model>`
|
||||
// prefix tracks the new name (see rekeyModelsOnProviderRename).
|
||||
const { models, aliasMap } = rekeyModelsOnProviderRename(
|
||||
cfg.models,
|
||||
editingProvider,
|
||||
provider.name
|
||||
);
|
||||
// The provider's remembered default model and the global default
|
||||
// model are stored as aliases and must follow the re-key.
|
||||
const remembered = getProviderDefaultModel(provider);
|
||||
providers[provider.name] =
|
||||
remembered && aliasMap[remembered]
|
||||
? setProviderDefaultModel(provider, aliasMap[remembered])
|
||||
: provider;
|
||||
let default_model = cfg.default_model;
|
||||
if (default_model && aliasMap[default_model]) {
|
||||
default_model = aliasMap[default_model];
|
||||
}
|
||||
providers[provider.name] = provider;
|
||||
// The subagent pool references model aliases as its keys and
|
||||
// defaults; they must follow the re-key or they turn into zombie
|
||||
// pool entries.
|
||||
const raw_other = rekeySubagentPoolAliases(cfg.raw_other, aliasMap);
|
||||
setEditingProvider(provider.name);
|
||||
return { ...cfg, providers, models };
|
||||
return { ...cfg, providers, models, default_model, raw_other };
|
||||
}
|
||||
providers[provider.name] = provider;
|
||||
return { ...cfg, providers };
|
||||
@@ -308,12 +328,21 @@ export default function App() {
|
||||
const providers = { ...cfg.providers };
|
||||
delete providers[name];
|
||||
const models = { ...cfg.models };
|
||||
const removedAliases: string[] = [];
|
||||
for (const key of Object.keys(models)) {
|
||||
if (models[key].provider === name) {
|
||||
removedAliases.push(key);
|
||||
delete models[key];
|
||||
}
|
||||
}
|
||||
return { ...cfg, providers, models };
|
||||
// Everything keyed on the deleted aliases must go with them: the
|
||||
// global default model and the subagent pool entries / pool default.
|
||||
const default_model =
|
||||
cfg.default_model && removedAliases.includes(cfg.default_model)
|
||||
? null
|
||||
: cfg.default_model;
|
||||
const raw_other = purgeSubagentPoolAliases(cfg.raw_other, removedAliases);
|
||||
return { ...cfg, providers, models, default_model, raw_other };
|
||||
});
|
||||
if (editingProvider === name) {
|
||||
setEditingProvider("");
|
||||
@@ -356,29 +385,14 @@ export default function App() {
|
||||
null
|
||||
);
|
||||
const providers = { ...cfg.providers, [newName]: copied };
|
||||
// Re-key this provider's models to the new provider name so the copy
|
||||
// has its own independent model set.
|
||||
const models = { ...cfg.models };
|
||||
for (const [alias, m] of Object.entries(cfg.models)) {
|
||||
if (m.provider === name) {
|
||||
// The model belongs to this provider by `m.provider === name`. To
|
||||
// re-key it under the new provider, we want a `newName/...`
|
||||
// alias. The original code assumed `alias` is exactly
|
||||
// `${name}/${modelId}` and used `alias.slice(name.length)`; that
|
||||
// breaks for legacy / non-standard aliases (e.g. `kimi-k3` from
|
||||
// pre-v0.6 data) which would produce `newName-k3` — missing the
|
||||
// `/` separator and pointing at the wrong model. Use an explicit
|
||||
// prefix check and fall back to a full re-prefix.
|
||||
const prefix = `${name}/`;
|
||||
const newAlias = alias.startsWith(prefix)
|
||||
? newName + alias.slice(name.length)
|
||||
: `${newName}/${alias}`;
|
||||
models[newAlias] = { ...m, alias: newAlias, provider: newName };
|
||||
}
|
||||
}
|
||||
// Clone the provider's models under the duplicate's name — COPY
|
||||
// semantics: the source keeps its models (a rekey would move them).
|
||||
const models = copyModelsOnProviderDuplicate(cfg.models, name, newName);
|
||||
return { ...cfg, providers, models };
|
||||
});
|
||||
await save();
|
||||
// No immediate save: the copy lives in memory only, like every other
|
||||
// edit. Abandoning (back → discard) must leave the config unchanged;
|
||||
// the copy persists when the user saves (Ctrl+S / footer / switch).
|
||||
setSwitchMessage(t("copiedProvider", { name: `${name}-copy` }));
|
||||
setTimeout(() => setSwitchMessage(null), 3000);
|
||||
};
|
||||
@@ -529,6 +543,14 @@ export default function App() {
|
||||
default_model = null;
|
||||
}
|
||||
|
||||
// Pool entries pointing at aliases that disappeared in this apply
|
||||
// (provider renamed, models dropped) must not survive as zombies.
|
||||
const removedAliases = Object.keys(cfg.models).filter(
|
||||
(key) =>
|
||||
cfg.models[key].provider === oldName && !(key in updatedModels)
|
||||
);
|
||||
const raw_other = purgeSubagentPoolAliases(cfg.raw_other, removedAliases);
|
||||
|
||||
// Remember this provider's default model preference so it survives
|
||||
// provider switches.
|
||||
const updatedProvider = providers[provider.name];
|
||||
@@ -536,7 +558,7 @@ export default function App() {
|
||||
providers[provider.name] = setProviderDefaultModel(updatedProvider, default_model);
|
||||
}
|
||||
|
||||
return { ...cfg, providers, models: updatedModels, default_model };
|
||||
return { ...cfg, providers, models: updatedModels, default_model, raw_other };
|
||||
});
|
||||
if (provider.name !== editingProvider) {
|
||||
setEditingProvider(provider.name);
|
||||
@@ -744,16 +766,26 @@ export default function App() {
|
||||
if (default_model === oldKey) default_model = effective.alias;
|
||||
else if (effective !== model && default_model === model.alias)
|
||||
default_model = effective.alias;
|
||||
return { ...cfg, models, default_model };
|
||||
// Pool entries keyed on the discarded alias (placeholder or
|
||||
// replaced old key) must follow the rename or they dangle.
|
||||
const poolAliasMap: Record<string, string> = {};
|
||||
if (effective !== model) poolAliasMap[model.alias] = effective.alias;
|
||||
if (oldKey && oldKey !== effective.alias)
|
||||
poolAliasMap[oldKey] = effective.alias;
|
||||
const raw_other = rekeySubagentPoolAliases(cfg.raw_other, poolAliasMap);
|
||||
return { ...cfg, models, default_model, raw_other };
|
||||
});
|
||||
}}
|
||||
onModelDelete={(alias) => {
|
||||
updateConfig((cfg) => {
|
||||
const models = { ...cfg.models };
|
||||
delete models[alias];
|
||||
// Pool entries keyed on the deleted alias must not survive.
|
||||
const raw_other = purgeSubagentPoolAliases(cfg.raw_other, [alias]);
|
||||
return {
|
||||
...cfg,
|
||||
models,
|
||||
raw_other,
|
||||
default_model:
|
||||
cfg.default_model === alias ? null : cfg.default_model,
|
||||
};
|
||||
|
||||
+1126
-1344
File diff suppressed because it is too large.
Load diff
+1052
-1253
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
copyModelsOnProviderDuplicate,
|
||||
rekeyModelsOnProviderRename,
|
||||
} from "./providerRename";
|
||||
import type { Model } from "../types";
|
||||
|
||||
function model(alias: string, provider: string): Model {
|
||||
return {
|
||||
alias,
|
||||
provider,
|
||||
model: alias.slice(provider.length + 1),
|
||||
max_context_size: 512000,
|
||||
display_name: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("rekeyModelsOnProviderRename", () => {
|
||||
it("re-keys aliases and provider when a provider is renamed (opencode-go-copy scenario)", () => {
|
||||
const models = {
|
||||
"opencode-go-copy/deepseek-v4-flash": model("opencode-go-copy/deepseek-v4-flash", "opencode-go-copy"),
|
||||
"opencode-go-copy/deepseek-v4.1-flash": model("opencode-go-copy/deepseek-v4.1-flash", "opencode-go-copy"),
|
||||
"opencode-go/longcat-2.5-preview-free": model("opencode-go/longcat-2.5-preview-free", "opencode-go"),
|
||||
"other/m1": model("other/m1", "other"),
|
||||
};
|
||||
|
||||
const { models: next, aliasMap } = rekeyModelsOnProviderRename(models, "opencode-go-copy", "opencode-go");
|
||||
|
||||
expect(Object.keys(next).sort()).toEqual([
|
||||
"opencode-go/deepseek-v4-flash",
|
||||
"opencode-go/deepseek-v4.1-flash",
|
||||
"opencode-go/longcat-2.5-preview-free",
|
||||
"other/m1",
|
||||
]);
|
||||
expect(next["opencode-go/deepseek-v4-flash"].provider).toBe("opencode-go");
|
||||
expect(next["opencode-go/deepseek-v4-flash"].model).toBe("deepseek-v4-flash");
|
||||
expect(aliasMap).toEqual({
|
||||
"opencode-go-copy/deepseek-v4-flash": "opencode-go/deepseek-v4-flash",
|
||||
"opencode-go-copy/deepseek-v4.1-flash": "opencode-go/deepseek-v4.1-flash",
|
||||
});
|
||||
// Untouched providers stay identical.
|
||||
expect(next["opencode-go/longcat-2.5-preview-free"]).toBe(models["opencode-go/longcat-2.5-preview-free"]);
|
||||
expect(next["other/m1"]).toBe(models["other/m1"]);
|
||||
});
|
||||
|
||||
it("fully re-prefixes non-standard aliases missing the old `<name>/` prefix", () => {
|
||||
const models = { "legacy-k3": model("legacy-k3", "kimi-code") };
|
||||
const { models: next } = rekeyModelsOnProviderRename(models, "kimi-code", "renamed");
|
||||
expect(Object.keys(next)).toEqual(["renamed/legacy-k3"]);
|
||||
expect(next["renamed/legacy-k3"].provider).toBe("renamed");
|
||||
});
|
||||
|
||||
it("leaves other providers' models alone when renaming", () => {
|
||||
const models = { "a/m1": model("a/m1", "a"), "b/m1": model("b/m1", "b") };
|
||||
const { models: next, aliasMap } = rekeyModelsOnProviderRename(models, "a", "c");
|
||||
expect(Object.keys(next).sort()).toEqual(["b/m1", "c/m1"]);
|
||||
expect(aliasMap).toEqual({ "a/m1": "c/m1" });
|
||||
});
|
||||
|
||||
it("suffixes instead of overwriting when the renamed alias collides (rename into an occupied name)", () => {
|
||||
// Renaming provider `a` to `b` while `b` already has b/m1 — the re-keyed
|
||||
// entry must not silently swallow b's model.
|
||||
const models = {
|
||||
"a/m1": model("a/m1", "a"),
|
||||
"b/m1": model("b/m1", "b"),
|
||||
};
|
||||
const { models: next, aliasMap } = rekeyModelsOnProviderRename(models, "a", "b");
|
||||
expect(next["b/m1"].model).toBe("m1"); // b's original survives
|
||||
expect(next["b/m1-2"].provider).toBe("b"); // a's model re-keyed with suffix
|
||||
expect(aliasMap).toEqual({ "a/m1": "b/m1-2" });
|
||||
expect(Object.keys(next).sort()).toEqual(["b/m1", "b/m1-2"]);
|
||||
});
|
||||
|
||||
it("suffixes when a legacy bare alias collides with the standard alias after rename", () => {
|
||||
// Pre-v0.6 data: bare alias `k3` (no provider prefix) and `kimi/k3` both
|
||||
// belong to `kimi`; both re-key to `x/k3`.
|
||||
const models = {
|
||||
k3: { ...model("k3", "kimi"), model: "k3" },
|
||||
"kimi/k3": model("kimi/k3", "kimi"),
|
||||
};
|
||||
const { models: next, aliasMap } = rekeyModelsOnProviderRename(models, "kimi", "x");
|
||||
expect(Object.keys(next).sort()).toEqual(["x/k3", "x/k3-2"]);
|
||||
expect(next["x/k3"]).toBeDefined();
|
||||
expect(next["x/k3-2"]).toBeDefined();
|
||||
// Both old keys are mapped; the pool re-key consumes the map, so every
|
||||
// entry lands on a distinct target.
|
||||
expect(Object.values(aliasMap).sort()).toEqual(["x/k3", "x/k3-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("copyModelsOnProviderDuplicate", () => {
|
||||
it("clones the provider's models and KEEPS the source (copy, not move)", () => {
|
||||
const models = {
|
||||
"opencode-go/longcat": model("opencode-go/longcat", "opencode-go"),
|
||||
"other/m1": model("other/m1", "other"),
|
||||
};
|
||||
const next = copyModelsOnProviderDuplicate(models, "opencode-go", "opencode-go-copy");
|
||||
expect(Object.keys(next).sort()).toEqual([
|
||||
"opencode-go-copy/longcat",
|
||||
"opencode-go/longcat",
|
||||
"other/m1",
|
||||
]);
|
||||
expect(next["opencode-go/longcat"].provider).toBe("opencode-go");
|
||||
expect(next["opencode-go-copy/longcat"].provider).toBe("opencode-go-copy");
|
||||
});
|
||||
|
||||
it("fully re-prefixes non-standard aliases and suffixes collisions", () => {
|
||||
const models = {
|
||||
"legacy-k3": { ...model("legacy-k3", "kimi"), model: "k3" },
|
||||
"dupe/legacy-k3": model("dupe/legacy-k3", "other"),
|
||||
};
|
||||
const next = copyModelsOnProviderDuplicate(models, "kimi", "dupe");
|
||||
// `dupe/legacy-k3` is taken → the clone gets a suffix.
|
||||
expect(Object.keys(next).sort()).toEqual([
|
||||
"dupe/legacy-k3",
|
||||
"dupe/legacy-k3-2",
|
||||
"legacy-k3",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Model } from "../types";
|
||||
|
||||
export interface ProviderRenameResult {
|
||||
models: Record<string, Model>;
|
||||
/** old alias → new alias (collision-deduped), for callers fixing alias references. */
|
||||
aliasMap: Record<string, string>;
|
||||
}
|
||||
|
||||
function rekeyedAlias(alias: string, oldName: string, newName: string): string {
|
||||
const prefix = `${oldName}/`;
|
||||
return alias.startsWith(prefix)
|
||||
? newName + alias.slice(oldName.length)
|
||||
: `${newName}/${alias}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-key a provider's model aliases after the provider is renamed so the
|
||||
* `<provider>/<model>` prefix tracks the new name. Only patching
|
||||
* `m.provider` (the old behaviour) left stale prefixes like
|
||||
* `opencode-go-copy/...` pointing at the renamed provider — zombie aliases
|
||||
* that the UI then re-saved to config.toml and SQLite forever.
|
||||
*
|
||||
* Collision-safe: a target alias that is already taken (renaming into an
|
||||
* existing provider's name, or a legacy bare alias colliding with a standard
|
||||
* `<name>/<model>` one — both map to the same new key) gets a `-2`/`-3`
|
||||
* suffix instead of silently overwriting the other entry. Non-standard
|
||||
* aliases without the `<oldName>/` prefix fall back to a full re-prefix.
|
||||
*/
|
||||
export function rekeyModelsOnProviderRename(
|
||||
models: Record<string, Model>,
|
||||
oldName: string,
|
||||
newName: string
|
||||
): ProviderRenameResult {
|
||||
const next: Record<string, Model> = {};
|
||||
const toRename: Array<[string, Model]> = [];
|
||||
for (const [key, m] of Object.entries(models)) {
|
||||
if (m.provider === oldName) toRename.push([key, m]);
|
||||
else next[key] = m;
|
||||
}
|
||||
const aliasMap: Record<string, string> = {};
|
||||
for (const [key, m] of toRename) {
|
||||
const base = rekeyedAlias(key, oldName, newName);
|
||||
let target = base;
|
||||
let n = 2;
|
||||
while (target in next) {
|
||||
target = `${base}-${n}`;
|
||||
n++;
|
||||
}
|
||||
next[target] = { ...m, alias: target, provider: newName };
|
||||
aliasMap[key] = target;
|
||||
}
|
||||
return { models: next, aliasMap };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a provider's models under the duplicate's name — COPY semantics:
|
||||
* the source provider keeps its models (unlike rekeyModelsOnProviderRename,
|
||||
* which moves them). Collisions with any existing alias get a `-2`/`-3`
|
||||
* suffix.
|
||||
*/
|
||||
export function copyModelsOnProviderDuplicate(
|
||||
models: Record<string, Model>,
|
||||
oldName: string,
|
||||
newName: string
|
||||
): Record<string, Model> {
|
||||
const next = { ...models };
|
||||
for (const [key, m] of Object.entries(models)) {
|
||||
if (m.provider !== oldName) continue;
|
||||
const base = rekeyedAlias(key, oldName, newName);
|
||||
let target = base;
|
||||
let n = 2;
|
||||
while (target in next) {
|
||||
target = `${base}-${n}`;
|
||||
n++;
|
||||
}
|
||||
next[target] = { ...m, alias: target, provider: newName };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { purgeSubagentPoolAliases, rekeySubagentPoolAliases } from "./referenceCleanup";
|
||||
|
||||
const rawWithPool = {
|
||||
thinking: { enabled: true },
|
||||
secondary_model: {
|
||||
default_model: "opencode-go/longcat-2.5-preview-free",
|
||||
model: "opencode-go/longcat-2.5-preview-free",
|
||||
models: {
|
||||
"TokenHub/deepseek-v4.1-flash": "",
|
||||
"opencode-go/longcat-2.5-preview-free": "",
|
||||
"opencode-go/mimo-v2.6-flash": "",
|
||||
"opencode-go-copy/deepseek-v4-flash": "route to deepseek",
|
||||
},
|
||||
force: false,
|
||||
},
|
||||
services: { moonshot_fetch: { api_key: "" } },
|
||||
};
|
||||
|
||||
describe("rekeySubagentPoolAliases", () => {
|
||||
it("re-keys pool keys and defaults when the provider is renamed", () => {
|
||||
const next = rekeySubagentPoolAliases(rawWithPool, {
|
||||
"opencode-go-copy/deepseek-v4-flash": "opencode-go/deepseek-v4-flash",
|
||||
}) as typeof rawWithPool;
|
||||
|
||||
const section = next.secondary_model as Record<string, any>;
|
||||
expect(Object.keys(section.models).sort()).toEqual([
|
||||
"TokenHub/deepseek-v4.1-flash",
|
||||
"opencode-go/deepseek-v4-flash",
|
||||
"opencode-go/longcat-2.5-preview-free",
|
||||
"opencode-go/mimo-v2.6-flash",
|
||||
]);
|
||||
// Descriptions survive the re-key.
|
||||
expect(section.models["opencode-go/deepseek-v4-flash"]).toBe("route to deepseek");
|
||||
// Untouched defaults stay put.
|
||||
expect(section.default_model).toBe("opencode-go/longcat-2.5-preview-free");
|
||||
// Unrelated sections are preserved.
|
||||
expect(next.thinking).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it("follows the default when it referenced a renamed alias", () => {
|
||||
const raw = {
|
||||
secondary_model: {
|
||||
default_model: "old/m1",
|
||||
model: "old/m1",
|
||||
models: { "old/m1": "" },
|
||||
},
|
||||
};
|
||||
const next = rekeySubagentPoolAliases(raw, { "old/m1": "new/m1" }) as any;
|
||||
expect(next.secondary_model.default_model).toBe("new/m1");
|
||||
expect(next.secondary_model.model).toBe("new/m1");
|
||||
expect(Object.keys(next.secondary_model.models)).toEqual(["new/m1"]);
|
||||
});
|
||||
|
||||
it("returns the same reference when nothing matches", () => {
|
||||
expect(rekeySubagentPoolAliases(rawWithPool, { "a/b": "c/d" })).toBe(rawWithPool);
|
||||
});
|
||||
|
||||
it("never injects an empty models table into the implicit single-model form", () => {
|
||||
// `{ model: "old/m1" }` with no pool table: re-keying the default must
|
||||
// not add `[secondary_model.models]` — an empty table is invalid for the
|
||||
// engine (its default has to be a key of the table).
|
||||
const raw = { secondary_model: { model: "old/m1" } };
|
||||
const next = rekeySubagentPoolAliases(raw, { "old/m1": "new/m1" }) as any;
|
||||
expect(next.secondary_model).toEqual({ model: "new/m1" });
|
||||
expect(next.secondary_model.models).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never injects a models table into the force form", () => {
|
||||
const raw = { secondary_model: { force: true, default_model: "old/m1", model: "old/m1" } };
|
||||
const next = rekeySubagentPoolAliases(raw, { "old/m1": "new/m1" }) as any;
|
||||
expect(next.secondary_model).toEqual({
|
||||
force: true,
|
||||
default_model: "new/m1",
|
||||
model: "new/m1",
|
||||
});
|
||||
expect(next.secondary_model.models).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("purgeSubagentPoolAliases", () => {
|
||||
it("removes deleted aliases and keeps the rest of the section intact", () => {
|
||||
const next = purgeSubagentPoolAliases(rawWithPool, [
|
||||
"opencode-go-copy/deepseek-v4-flash",
|
||||
]) as typeof rawWithPool;
|
||||
const section = next.secondary_model as Record<string, any>;
|
||||
expect(Object.keys(section.models).sort()).toEqual([
|
||||
"TokenHub/deepseek-v4.1-flash",
|
||||
"opencode-go/longcat-2.5-preview-free",
|
||||
"opencode-go/mimo-v2.6-flash",
|
||||
]);
|
||||
expect(section.default_model).toBe("opencode-go/longcat-2.5-preview-free");
|
||||
expect(section.force).toBe(false);
|
||||
expect(next.thinking).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it("falls back to the first remaining key when the default was purged", () => {
|
||||
const next = purgeSubagentPoolAliases(rawWithPool, [
|
||||
"opencode-go/longcat-2.5-preview-free",
|
||||
]) as any;
|
||||
const section = next.secondary_model;
|
||||
expect(section.models).not.toHaveProperty("opencode-go/longcat-2.5-preview-free");
|
||||
expect(section.default_model).toBe("TokenHub/deepseek-v4.1-flash");
|
||||
expect(section.model).toBe("TokenHub/deepseek-v4.1-flash");
|
||||
});
|
||||
|
||||
it("drops the whole section when the pool becomes empty", () => {
|
||||
const raw = {
|
||||
thinking: { enabled: true },
|
||||
secondary_model: {
|
||||
default_model: "p/m",
|
||||
model: "p/m",
|
||||
models: { "p/m": "" },
|
||||
},
|
||||
};
|
||||
const next = purgeSubagentPoolAliases(raw, ["p/m"]) as any;
|
||||
expect(next).not.toHaveProperty("secondary_model");
|
||||
expect(next.thinking).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it("returns the same reference when no pool entry is affected", () => {
|
||||
expect(purgeSubagentPoolAliases(rawWithPool, ["other/m9"])).toBe(rawWithPool);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Keep `[secondary_model]` (the subagent model pool in `raw_other`) free of
|
||||
* dangling model-alias references.
|
||||
*
|
||||
* Model aliases are deleted or re-keyed in several places (provider delete,
|
||||
* single-model delete, provider rename). The pool stores aliases as its
|
||||
* `models` table keys and as the `default_model` / `model` values — none of
|
||||
* those may outlive the alias they point at, or the engine resolves the pool
|
||||
* entry to nothing and the zombie key lingers in config.toml forever.
|
||||
*/
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-key pool aliases after a provider rename: `aliasMap` maps old alias →
|
||||
* new alias. Pool keys keep their descriptions; `default_model` / `model`
|
||||
* follow when they referenced a renamed alias. Returns the input reference
|
||||
* when nothing changed.
|
||||
*/
|
||||
export function rekeySubagentPoolAliases(
|
||||
rawOther: unknown,
|
||||
aliasMap: Record<string, string>
|
||||
): unknown {
|
||||
if (Object.keys(aliasMap).length === 0) return rawOther;
|
||||
const root = asRecord(rawOther);
|
||||
const section = asRecord(root.secondary_model);
|
||||
if (Object.keys(section).length === 0) return rawOther;
|
||||
|
||||
const models = asRecord(section.models);
|
||||
let changed = false;
|
||||
const nextModels: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(models)) {
|
||||
const mapped = aliasMap[k];
|
||||
if (mapped && mapped !== k) {
|
||||
nextModels[mapped] = v;
|
||||
changed = true;
|
||||
} else {
|
||||
nextModels[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
let nextDefault = section.default_model;
|
||||
let nextModel = section.model;
|
||||
if (typeof nextDefault === "string" && aliasMap[nextDefault]) {
|
||||
nextDefault = aliasMap[nextDefault];
|
||||
changed = true;
|
||||
}
|
||||
if (typeof nextModel === "string" && aliasMap[nextModel]) {
|
||||
nextModel = aliasMap[nextModel];
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return rawOther;
|
||||
|
||||
// Only write a `models` table when the section had one — the implicit
|
||||
// single-model form (`{ model: "..." }`) and the force form must not gain
|
||||
// an empty `[secondary_model.models]` table, which the engine can never
|
||||
// validate (its default must be a key of the table).
|
||||
const hadModelsTable = section.models !== undefined;
|
||||
const nextSection: Record<string, unknown> = { ...section };
|
||||
if (hadModelsTable || Object.keys(nextModels).length > 0) {
|
||||
nextSection.models = nextModels;
|
||||
}
|
||||
if (nextDefault !== section.default_model) nextSection.default_model = nextDefault;
|
||||
if (nextModel !== section.model) nextSection.model = nextModel;
|
||||
return { ...root, secondary_model: nextSection };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove pool entries pointing at deleted aliases. When the pool's default
|
||||
* referenced a deleted alias it falls back to the first remaining key; when
|
||||
* the pool becomes empty the whole `[secondary_model]` section is dropped
|
||||
* (an empty pool is invalid for the engine — subagents then inherit the
|
||||
* primary model). Returns the input reference when nothing changed.
|
||||
*/
|
||||
export function purgeSubagentPoolAliases(
|
||||
rawOther: unknown,
|
||||
removedAliases: Iterable<string>
|
||||
): unknown {
|
||||
const removed = new Set(removedAliases);
|
||||
if (removed.size === 0) return rawOther;
|
||||
const root = asRecord(rawOther);
|
||||
const section = asRecord(root.secondary_model);
|
||||
if (Object.keys(section).length === 0) return rawOther;
|
||||
|
||||
const models = asRecord(section.models);
|
||||
const kept: Record<string, unknown> = {};
|
||||
let removedAny = false;
|
||||
for (const [k, v] of Object.entries(models)) {
|
||||
if (removed.has(k)) {
|
||||
removedAny = true;
|
||||
} else {
|
||||
kept[k] = v;
|
||||
}
|
||||
}
|
||||
const defaultGone =
|
||||
(typeof section.default_model === "string" && removed.has(section.default_model)) ||
|
||||
(typeof section.model === "string" && removed.has(section.model));
|
||||
if (!removedAny && !defaultGone) return rawOther;
|
||||
|
||||
if (Object.keys(kept).length === 0) {
|
||||
const nextRoot: Record<string, unknown> = { ...root };
|
||||
delete nextRoot.secondary_model;
|
||||
return nextRoot;
|
||||
}
|
||||
|
||||
const nextSection: Record<string, unknown> = { ...section, models: kept };
|
||||
if (defaultGone) {
|
||||
const fallback = Object.keys(kept)[0];
|
||||
nextSection.default_model = fallback;
|
||||
nextSection.model = fallback;
|
||||
}
|
||||
return { ...root, secondary_model: nextSection };
|
||||
}
|
||||
Reference in new issue
Block a user