mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-13 06:03:39 -06:00
1f4740e95f
* fix(opencode): migrate retired free model refs Repair the shipped beta upgrade path through the provider-owned Doctor contract. Activate the migration from the canonical configured-model selector inventory so every supported selector is covered consistently. Co-authored-by: samson1357924 <samson1357924@gmail.com> * fix(opencode): migrate media model preferences --------- Co-authored-by: samson1357924 <samson1357924@gmail.com>
307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
|
import { describe, expect, it } from "vitest";
|
|
import { normalizeCompatibilityConfig } from "./doctor-contract-api.js";
|
|
|
|
const RETIRED = "opencode/hy3-free";
|
|
const REPLACEMENT = "opencode/laguna-s-2.1-free";
|
|
|
|
describe("OpenCode doctor compatibility normalization", () => {
|
|
it("repairs every canonical model surface and removes retired catalog rows", () => {
|
|
const input = {
|
|
unrelated: { preserved: true, apiKey: "must-not-appear" },
|
|
agents: {
|
|
defaults: {
|
|
model: {
|
|
primary: "OpenCode/HY3-FREE",
|
|
fallbacks: [`${RETIRED}@secret-profile`, "other/model", 42],
|
|
},
|
|
utilityModel: RETIRED,
|
|
imageModel: { primary: RETIRED },
|
|
voiceModel: RETIRED,
|
|
pdfModel: RETIRED,
|
|
mediaModels: {
|
|
image: RETIRED,
|
|
video: { primary: RETIRED, fallbacks: [RETIRED] },
|
|
music: RETIRED,
|
|
},
|
|
heartbeat: { model: RETIRED },
|
|
subagents: { model: { primary: RETIRED, fallbacks: [RETIRED] } },
|
|
compaction: { model: RETIRED, memoryFlush: { model: RETIRED } },
|
|
models: {
|
|
[RETIRED]: { alias: "retired" },
|
|
[REPLACEMENT]: { alias: "canonical" },
|
|
"other/model": { alias: "other" },
|
|
},
|
|
modelPolicy: { allow: [RETIRED, "opencode/*", "alias"] },
|
|
},
|
|
entries: {
|
|
worker: {
|
|
model: RETIRED,
|
|
models: { [`${RETIRED}@work`]: { alias: "profiled" } },
|
|
modelPolicy: { allow: [`${RETIRED}@work`] },
|
|
tools: { exec: { reviewer: { model: RETIRED } } },
|
|
tts: { summaryModel: RETIRED },
|
|
},
|
|
},
|
|
list: [
|
|
{
|
|
id: "shadow",
|
|
model: RETIRED,
|
|
modelPolicy: { allow: [RETIRED] },
|
|
tools: { exec: { reviewer: { model: RETIRED } } },
|
|
tts: { summaryModel: RETIRED },
|
|
},
|
|
],
|
|
},
|
|
tools: { exec: { reviewer: { model: RETIRED } } },
|
|
hooks: {
|
|
mappings: [{ model: RETIRED }],
|
|
gmail: { model: RETIRED },
|
|
},
|
|
tts: { summaryModel: RETIRED },
|
|
channels: {
|
|
modelByChannel: { discord: { guild: RETIRED } },
|
|
discord: {
|
|
voice: { model: RETIRED, tts: { summaryModel: RETIRED } },
|
|
accounts: {
|
|
work: { voice: { model: RETIRED, tts: { summaryModel: RETIRED } } },
|
|
},
|
|
},
|
|
},
|
|
models: {
|
|
providers: {
|
|
opencode: {
|
|
models: [
|
|
{ id: "HY3-FREE", name: "retired" },
|
|
{ id: "laguna-s-2.1-free", name: "canonical" },
|
|
{ id: "custom", name: "custom" },
|
|
"malformed",
|
|
],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const original = structuredClone(input);
|
|
|
|
const first = normalizeCompatibilityConfig({
|
|
cfg: input as unknown as OpenClawConfig,
|
|
});
|
|
const migrated = first.config as unknown as typeof input;
|
|
|
|
expect(input).toEqual(original);
|
|
expect(migrated.unrelated).toEqual(input.unrelated);
|
|
expect(migrated.agents.defaults.model).toEqual({
|
|
primary: REPLACEMENT,
|
|
fallbacks: [`${REPLACEMENT}@secret-profile`, "other/model", 42],
|
|
});
|
|
expect([
|
|
migrated.agents.defaults.utilityModel,
|
|
migrated.agents.defaults.imageModel.primary,
|
|
migrated.agents.defaults.voiceModel,
|
|
migrated.agents.defaults.pdfModel,
|
|
migrated.agents.defaults.mediaModels.image,
|
|
migrated.agents.defaults.mediaModels.video.primary,
|
|
migrated.agents.defaults.mediaModels.video.fallbacks[0],
|
|
migrated.agents.defaults.mediaModels.music,
|
|
migrated.agents.defaults.heartbeat.model,
|
|
migrated.agents.defaults.subagents.model.primary,
|
|
migrated.agents.defaults.subagents.model.fallbacks[0],
|
|
migrated.agents.defaults.compaction.model,
|
|
migrated.agents.defaults.compaction.memoryFlush.model,
|
|
migrated.agents.entries.worker.model,
|
|
migrated.hooks.mappings[0]?.model,
|
|
migrated.hooks.gmail.model,
|
|
migrated.tts.summaryModel,
|
|
migrated.channels.modelByChannel.discord.guild,
|
|
migrated.channels.discord.voice.model,
|
|
migrated.channels.discord.voice.tts.summaryModel,
|
|
migrated.channels.discord.accounts.work.voice.model,
|
|
migrated.channels.discord.accounts.work.voice.tts.summaryModel,
|
|
migrated.tools.exec.reviewer.model,
|
|
migrated.agents.entries.worker.tools.exec.reviewer.model,
|
|
migrated.agents.entries.worker.tts.summaryModel,
|
|
]).toEqual(Array.from({ length: 25 }, () => REPLACEMENT));
|
|
expect(migrated.agents.defaults.models).toEqual({
|
|
[REPLACEMENT]: { alias: "canonical" },
|
|
"other/model": { alias: "other" },
|
|
});
|
|
expect(migrated.agents.defaults.modelPolicy.allow).toEqual([
|
|
REPLACEMENT,
|
|
"opencode/*",
|
|
"alias",
|
|
]);
|
|
expect(migrated.agents.entries.worker.models).toEqual({
|
|
[`${REPLACEMENT}@work`]: { alias: "profiled" },
|
|
});
|
|
expect(migrated.agents.entries.worker.modelPolicy.allow).toEqual([`${REPLACEMENT}@work`]);
|
|
expect(migrated.agents.list).toEqual(input.agents.list);
|
|
expect(migrated.models.providers.opencode.models).toEqual([
|
|
{ id: "laguna-s-2.1-free", name: "canonical" },
|
|
{ id: "custom", name: "custom" },
|
|
"malformed",
|
|
]);
|
|
expect(first.changes).toEqual(
|
|
expect.arrayContaining([
|
|
expect.stringContaining("agents.defaults.model.primary"),
|
|
expect.stringContaining("agents.defaults.modelPolicy.allow.0"),
|
|
expect.stringContaining("agents.entries.worker.models"),
|
|
expect.stringContaining("channels.discord.voice.model"),
|
|
expect.stringContaining("models.providers.opencode.models"),
|
|
]),
|
|
);
|
|
expect(first.changes.join("\n")).not.toContain("secret-profile");
|
|
expect(first.changes.join("\n")).not.toContain("must-not-appear");
|
|
|
|
const second = normalizeCompatibilityConfig({ cfg: first.config });
|
|
expect(second).toEqual({ config: first.config, changes: [] });
|
|
expect(second.config).toBe(first.config);
|
|
});
|
|
|
|
it("uses the legacy list only when keyed entries are absent", () => {
|
|
const legacy = {
|
|
agents: {
|
|
list: [
|
|
{
|
|
id: "legacy",
|
|
model: RETIRED,
|
|
modelPolicy: { allow: [RETIRED] },
|
|
tools: { exec: { reviewer: { model: RETIRED } } },
|
|
tts: { summaryModel: RETIRED },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const result = normalizeCompatibilityConfig({
|
|
cfg: legacy as unknown as OpenClawConfig,
|
|
}).config as unknown as typeof legacy;
|
|
|
|
expect(result.agents.list[0]).toEqual({
|
|
id: "legacy",
|
|
model: REPLACEMENT,
|
|
modelPolicy: { allow: [REPLACEMENT] },
|
|
tools: { exec: { reviewer: { model: REPLACEMENT } } },
|
|
tts: { summaryModel: REPLACEMENT },
|
|
});
|
|
expect(legacy.agents.list[0]?.model).toBe(RETIRED);
|
|
});
|
|
|
|
it("never rewrites a shadow list when entries exists", () => {
|
|
const input = {
|
|
agents: {
|
|
entries: {},
|
|
list: [{ id: "shadow", model: RETIRED, modelPolicy: { allow: [RETIRED] } }],
|
|
},
|
|
};
|
|
|
|
const result = normalizeCompatibilityConfig({
|
|
cfg: input as unknown as OpenClawConfig,
|
|
});
|
|
|
|
expect(result).toEqual({ config: input, changes: [] });
|
|
expect(result.config).toBe(input);
|
|
});
|
|
|
|
it("never rewrites a shadow list when entries is null", () => {
|
|
const input = {
|
|
agents: {
|
|
entries: null,
|
|
list: [
|
|
{
|
|
id: "shadow",
|
|
model: RETIRED,
|
|
tools: { exec: { reviewer: { model: RETIRED } } },
|
|
tts: { summaryModel: RETIRED },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
expect(normalizeCompatibilityConfig({ cfg: input as unknown as OpenClawConfig })).toEqual({
|
|
config: input,
|
|
changes: [],
|
|
});
|
|
});
|
|
|
|
it("repairs only structured OpenCode provider media entries", () => {
|
|
const input = {
|
|
tools: {
|
|
media: {
|
|
image: { preferredModel: "opencode/hy3-free" },
|
|
audio: { preferredModel: "hy3-free" },
|
|
video: { preferredModel: "OPENCODE/HY3-FREE@work" },
|
|
models: [
|
|
{
|
|
provider: " OpenCode ",
|
|
model: " HY3-FREE ",
|
|
profile: "work",
|
|
capabilities: ["image"],
|
|
},
|
|
{ type: "cli", provider: "opencode", model: "hy3-free", command: "opencode" },
|
|
{ provider: "opencode", model: "hy3-free", command: "opencode" },
|
|
{ provider: "other", model: "hy3-free" },
|
|
{ provider: "opencode", model: "opencode/hy3-free" },
|
|
{ provider: "opencode", model: "hy3-free@work" },
|
|
"malformed",
|
|
],
|
|
},
|
|
},
|
|
};
|
|
const original = structuredClone(input);
|
|
|
|
const result = normalizeCompatibilityConfig({
|
|
cfg: input as unknown as OpenClawConfig,
|
|
});
|
|
const migrated = result.config as unknown as typeof input;
|
|
|
|
expect(input).toEqual(original);
|
|
expect(migrated.tools.media.models[0]).toEqual({
|
|
provider: "opencode",
|
|
model: "laguna-s-2.1-free",
|
|
profile: "work",
|
|
capabilities: ["image"],
|
|
});
|
|
expect(migrated.tools.media.models.slice(1)).toEqual(input.tools.media.models.slice(1));
|
|
expect(migrated.tools.media.image.preferredModel).toBe(REPLACEMENT);
|
|
expect(migrated.tools.media.audio.preferredModel).toBe("hy3-free");
|
|
expect(migrated.tools.media.video.preferredModel).toBe(`${REPLACEMENT}@work`);
|
|
expect(result.changes).toEqual([
|
|
"Updated tools.media.models.0 to the current OpenCode Zen model.",
|
|
`Updated tools.media.image.preferredModel from the retired OpenCode Zen model to ${REPLACEMENT}.`,
|
|
`Updated tools.media.video.preferredModel from the retired OpenCode Zen model to ${REPLACEMENT}.`,
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
"hy3-free",
|
|
"opencode-go/hy3",
|
|
"opencode/*",
|
|
"prefix opencode/hy3-free suffix",
|
|
"opencode/hy3-free@",
|
|
"opencode/hy3-free@profile/with/slash",
|
|
])("does not rewrite non-exact ref %s", (model) => {
|
|
const input = { agents: { defaults: { model } } };
|
|
const result = normalizeCompatibilityConfig({ cfg: input as OpenClawConfig });
|
|
|
|
expect(result).toEqual({ config: input, changes: [] });
|
|
expect(result.config).toBe(input);
|
|
});
|
|
|
|
it("ignores non-string and malformed selector values", () => {
|
|
const input = {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: 42, fallbacks: [null, { model: RETIRED }] },
|
|
modelPolicy: { allow: [42, null] },
|
|
models: [RETIRED],
|
|
},
|
|
},
|
|
};
|
|
const result = normalizeCompatibilityConfig({
|
|
cfg: input as unknown as OpenClawConfig,
|
|
});
|
|
|
|
expect(result).toEqual({ config: input, changes: [] });
|
|
});
|
|
});
|