mirror of https://github.com/openclaw/openclaw.git
441 lines
12 KiB
TypeScript
441 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const {
|
|
loadModelCatalogMock,
|
|
getModelRefStatusMock,
|
|
normalizeModelSelectionMock,
|
|
resolveAllowedModelRefMock,
|
|
resolveConfiguredModelRefMock,
|
|
resolveHooksGmailModelMock,
|
|
} = vi.hoisted(() => ({
|
|
loadModelCatalogMock: vi.fn(),
|
|
getModelRefStatusMock: vi.fn(),
|
|
normalizeModelSelectionMock: vi.fn((value: unknown) =>
|
|
typeof value === "string" && value.trim() ? value.trim() : undefined,
|
|
),
|
|
resolveAllowedModelRefMock: vi.fn(),
|
|
resolveConfiguredModelRefMock: vi.fn(),
|
|
resolveHooksGmailModelMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../agents/model-catalog.js", () => ({
|
|
loadModelCatalog: loadModelCatalogMock,
|
|
}));
|
|
|
|
vi.mock("../agents/model-selection.js", () => ({
|
|
getModelRefStatus: getModelRefStatusMock,
|
|
normalizeModelSelection: normalizeModelSelectionMock,
|
|
resolveAllowedModelRef: resolveAllowedModelRefMock,
|
|
resolveConfiguredModelRef: resolveConfiguredModelRefMock,
|
|
resolveHooksGmailModel: resolveHooksGmailModelMock,
|
|
}));
|
|
|
|
import { resolveCronModelSelection } from "./isolated-agent/model-selection.js";
|
|
|
|
const DEFAULT_MESSAGE = "do it";
|
|
const DEFAULT_PROVIDER = "anthropic";
|
|
const DEFAULT_MODEL = "claude-opus-4-5";
|
|
|
|
type AgentTurnPayload = {
|
|
kind: "agentTurn";
|
|
message: string;
|
|
deliver?: boolean;
|
|
model?: string;
|
|
};
|
|
|
|
type SelectModelOptions = {
|
|
cfg?: Record<string, unknown>;
|
|
agentConfigOverride?: {
|
|
subagents?: {
|
|
model?: unknown;
|
|
};
|
|
};
|
|
payload?: AgentTurnPayload;
|
|
sessionEntry?: {
|
|
modelOverride?: string;
|
|
providerOverride?: string;
|
|
};
|
|
isGmailHook?: boolean;
|
|
};
|
|
|
|
function parseModelRef(raw: string): { provider: string; model: string } | { error: string } {
|
|
const trimmed = raw.trim();
|
|
const slash = trimmed.indexOf("/");
|
|
if (slash <= 0 || slash === trimmed.length - 1) {
|
|
return { error: "invalid model" };
|
|
}
|
|
|
|
const providerRaw = trimmed.slice(0, slash).trim().toLowerCase();
|
|
const modelRaw = trimmed.slice(slash + 1).trim();
|
|
if (!providerRaw || !modelRaw) {
|
|
return { error: "invalid model" };
|
|
}
|
|
|
|
const provider = providerRaw === "bedrock" ? "amazon-bedrock" : providerRaw;
|
|
const model = provider === "anthropic" && modelRaw === "opus-4.5" ? "claude-opus-4-5" : modelRaw;
|
|
return { provider, model };
|
|
}
|
|
|
|
function resolveConfiguredModelForTest(cfg: Record<string, unknown>): {
|
|
provider: string;
|
|
model: string;
|
|
} {
|
|
const modelValue = (cfg.agents as { defaults?: { model?: unknown } } | undefined)?.defaults
|
|
?.model;
|
|
const rawModel =
|
|
typeof modelValue === "string"
|
|
? modelValue
|
|
: typeof modelValue === "object" &&
|
|
modelValue &&
|
|
typeof (modelValue as { primary?: unknown }).primary === "string"
|
|
? (modelValue as { primary: string }).primary
|
|
: undefined;
|
|
|
|
if (typeof rawModel === "string") {
|
|
const parsed = parseModelRef(rawModel);
|
|
if (!("error" in parsed)) {
|
|
return parsed;
|
|
}
|
|
}
|
|
|
|
return { provider: DEFAULT_PROVIDER, model: DEFAULT_MODEL };
|
|
}
|
|
|
|
function defaultPayload(): AgentTurnPayload {
|
|
return {
|
|
kind: "agentTurn",
|
|
message: DEFAULT_MESSAGE,
|
|
deliver: false,
|
|
};
|
|
}
|
|
|
|
async function selectModel(options: SelectModelOptions = {}) {
|
|
const cfg = options.cfg ?? {};
|
|
return resolveCronModelSelection({
|
|
cfg: cfg as never,
|
|
cfgWithAgentDefaults: cfg as never,
|
|
agentConfigOverride: options.agentConfigOverride,
|
|
sessionEntry: options.sessionEntry ?? {},
|
|
payload: options.payload ?? defaultPayload(),
|
|
isGmailHook: options.isGmailHook ?? false,
|
|
});
|
|
}
|
|
|
|
async function expectSelectedModel(
|
|
options: SelectModelOptions,
|
|
expected: { provider: string; model: string },
|
|
) {
|
|
const result = await selectModel(options);
|
|
expect(result).toEqual({ ok: true, ...expected });
|
|
}
|
|
|
|
async function expectDefaultSelectedModel(options: SelectModelOptions = {}) {
|
|
await expectSelectedModel(options, { provider: DEFAULT_PROVIDER, model: DEFAULT_MODEL });
|
|
}
|
|
|
|
describe("cron model formatting and precedence edge cases", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
loadModelCatalogMock.mockResolvedValue([]);
|
|
getModelRefStatusMock.mockReturnValue({ allowed: false });
|
|
resolveHooksGmailModelMock.mockReturnValue(null);
|
|
resolveConfiguredModelRefMock.mockImplementation(({ cfg }: { cfg?: Record<string, unknown> }) =>
|
|
resolveConfiguredModelForTest(cfg ?? {}),
|
|
);
|
|
resolveAllowedModelRefMock.mockImplementation(({ raw }: { raw: string }) => {
|
|
const parsed = parseModelRef(raw);
|
|
return "error" in parsed ? parsed : { ref: parsed };
|
|
});
|
|
});
|
|
|
|
describe("parseModelRef formatting", () => {
|
|
it("splits standard provider/model", async () => {
|
|
await expectSelectedModel(
|
|
{
|
|
payload: { kind: "agentTurn", message: DEFAULT_MESSAGE, model: "openai/gpt-4.1-mini" },
|
|
},
|
|
{ provider: "openai", model: "gpt-4.1-mini" },
|
|
);
|
|
});
|
|
|
|
it("handles leading/trailing whitespace in model string", async () => {
|
|
await expectSelectedModel(
|
|
{
|
|
payload: {
|
|
kind: "agentTurn",
|
|
message: DEFAULT_MESSAGE,
|
|
model: " openai/gpt-4.1-mini ",
|
|
},
|
|
},
|
|
{ provider: "openai", model: "gpt-4.1-mini" },
|
|
);
|
|
});
|
|
|
|
it("handles openrouter nested provider paths", async () => {
|
|
await expectSelectedModel(
|
|
{
|
|
payload: {
|
|
kind: "agentTurn",
|
|
message: DEFAULT_MESSAGE,
|
|
model: "openrouter/meta-llama/llama-3.3-70b:free",
|
|
},
|
|
},
|
|
{ provider: "openrouter", model: "meta-llama/llama-3.3-70b:free" },
|
|
);
|
|
});
|
|
|
|
it("rejects model with trailing slash (empty model name)", async () => {
|
|
await expect(
|
|
selectModel({
|
|
payload: { kind: "agentTurn", message: DEFAULT_MESSAGE, model: "openai/" },
|
|
}),
|
|
).resolves.toEqual({ ok: false, error: "invalid model" });
|
|
});
|
|
|
|
it("rejects model with leading slash (empty provider)", async () => {
|
|
await expect(
|
|
selectModel({
|
|
payload: { kind: "agentTurn", message: DEFAULT_MESSAGE, model: "/gpt-4.1-mini" },
|
|
}),
|
|
).resolves.toEqual({ ok: false, error: "invalid model" });
|
|
});
|
|
|
|
it("normalizes provider casing", async () => {
|
|
await expectSelectedModel(
|
|
{
|
|
payload: {
|
|
kind: "agentTurn",
|
|
message: DEFAULT_MESSAGE,
|
|
model: "OpenAI/gpt-4.1-mini",
|
|
},
|
|
},
|
|
{ provider: "openai", model: "gpt-4.1-mini" },
|
|
);
|
|
});
|
|
|
|
it("normalizes anthropic model aliases", async () => {
|
|
await expectSelectedModel(
|
|
{
|
|
payload: {
|
|
kind: "agentTurn",
|
|
message: DEFAULT_MESSAGE,
|
|
model: "anthropic/opus-4.5",
|
|
},
|
|
},
|
|
{ provider: "anthropic", model: "claude-opus-4-5" },
|
|
);
|
|
});
|
|
|
|
it("normalizes bedrock provider alias", async () => {
|
|
await expectSelectedModel(
|
|
{
|
|
payload: {
|
|
kind: "agentTurn",
|
|
message: DEFAULT_MESSAGE,
|
|
model: "bedrock/claude-sonnet-4-5",
|
|
},
|
|
},
|
|
{ provider: "amazon-bedrock", model: "claude-sonnet-4-5" },
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("model precedence isolation", () => {
|
|
it("job payload model overrides default (anthropic -> openai)", async () => {
|
|
await expectSelectedModel(
|
|
{
|
|
payload: {
|
|
kind: "agentTurn",
|
|
message: DEFAULT_MESSAGE,
|
|
model: "openai/gpt-4.1-mini",
|
|
},
|
|
},
|
|
{ provider: "openai", model: "gpt-4.1-mini" },
|
|
);
|
|
});
|
|
|
|
it("session override applies when no job payload model is present", async () => {
|
|
await expectSelectedModel(
|
|
{
|
|
sessionEntry: {
|
|
providerOverride: "openai",
|
|
modelOverride: "gpt-4.1-mini",
|
|
},
|
|
},
|
|
{ provider: "openai", model: "gpt-4.1-mini" },
|
|
);
|
|
});
|
|
|
|
it("job payload model wins over conflicting session override", async () => {
|
|
await expectSelectedModel(
|
|
{
|
|
payload: {
|
|
kind: "agentTurn",
|
|
message: DEFAULT_MESSAGE,
|
|
model: "anthropic/claude-sonnet-4-5",
|
|
deliver: false,
|
|
},
|
|
sessionEntry: {
|
|
providerOverride: "openai",
|
|
modelOverride: "gpt-4.1-mini",
|
|
},
|
|
},
|
|
{ provider: "anthropic", model: "claude-sonnet-4-5" },
|
|
);
|
|
});
|
|
|
|
it("falls through to default when no override is present", async () => {
|
|
await expectDefaultSelectedModel();
|
|
});
|
|
});
|
|
|
|
describe("sequential model switches (CI failure regression)", () => {
|
|
it("openai override -> session openai -> job anthropic: each step resolves correctly", async () => {
|
|
await expectSelectedModel(
|
|
{
|
|
payload: {
|
|
kind: "agentTurn",
|
|
message: DEFAULT_MESSAGE,
|
|
model: "openai/gpt-4.1-mini",
|
|
},
|
|
},
|
|
{ provider: "openai", model: "gpt-4.1-mini" },
|
|
);
|
|
|
|
await expectSelectedModel(
|
|
{
|
|
sessionEntry: {
|
|
providerOverride: "openai",
|
|
modelOverride: "gpt-4.1-mini",
|
|
},
|
|
},
|
|
{ provider: "openai", model: "gpt-4.1-mini" },
|
|
);
|
|
|
|
await expectSelectedModel(
|
|
{
|
|
payload: {
|
|
kind: "agentTurn",
|
|
message: DEFAULT_MESSAGE,
|
|
model: "anthropic/claude-opus-4-5",
|
|
deliver: false,
|
|
},
|
|
sessionEntry: {
|
|
providerOverride: "openai",
|
|
modelOverride: "gpt-4.1-mini",
|
|
},
|
|
},
|
|
{ provider: "anthropic", model: "claude-opus-4-5" },
|
|
);
|
|
});
|
|
|
|
it("provider does not leak between isolated sequential runs", async () => {
|
|
await expectSelectedModel(
|
|
{
|
|
payload: {
|
|
kind: "agentTurn",
|
|
message: DEFAULT_MESSAGE,
|
|
model: "openai/gpt-4.1-mini",
|
|
},
|
|
},
|
|
{ provider: "openai", model: "gpt-4.1-mini" },
|
|
);
|
|
|
|
await expectDefaultSelectedModel();
|
|
});
|
|
});
|
|
|
|
describe("stored session overrides", () => {
|
|
it("stored modelOverride/providerOverride are applied", async () => {
|
|
await expectSelectedModel(
|
|
{
|
|
sessionEntry: {
|
|
providerOverride: "openai",
|
|
modelOverride: "gpt-4.1-mini",
|
|
},
|
|
},
|
|
{ provider: "openai", model: "gpt-4.1-mini" },
|
|
);
|
|
});
|
|
|
|
it("default remains when store has no override", async () => {
|
|
await expectDefaultSelectedModel({ sessionEntry: {} });
|
|
});
|
|
});
|
|
|
|
describe("whitespace and empty model strings", () => {
|
|
it("whitespace-only model treated as unset (falls to default)", async () => {
|
|
await expectDefaultSelectedModel({
|
|
payload: { kind: "agentTurn", message: DEFAULT_MESSAGE, model: " " },
|
|
});
|
|
});
|
|
|
|
it("empty string model treated as unset", async () => {
|
|
await expectDefaultSelectedModel({
|
|
payload: { kind: "agentTurn", message: DEFAULT_MESSAGE, model: "" },
|
|
});
|
|
});
|
|
|
|
it("whitespace-only session modelOverride is ignored", async () => {
|
|
await expectDefaultSelectedModel({
|
|
sessionEntry: {
|
|
providerOverride: "openai",
|
|
modelOverride: " ",
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("config model format variations", () => {
|
|
it("default model as string 'provider/model'", async () => {
|
|
await expectSelectedModel(
|
|
{
|
|
cfg: {
|
|
agents: {
|
|
defaults: {
|
|
model: "openai/gpt-4.1",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{ provider: "openai", model: "gpt-4.1" },
|
|
);
|
|
});
|
|
|
|
it("default model as object with primary field", async () => {
|
|
await expectSelectedModel(
|
|
{
|
|
cfg: {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "openai/gpt-4.1" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{ provider: "openai", model: "gpt-4.1" },
|
|
);
|
|
});
|
|
|
|
it("job override switches away from object default", async () => {
|
|
await expectSelectedModel(
|
|
{
|
|
cfg: {
|
|
agents: {
|
|
defaults: {
|
|
model: { primary: "openai/gpt-4.1" },
|
|
},
|
|
},
|
|
},
|
|
payload: {
|
|
kind: "agentTurn",
|
|
message: DEFAULT_MESSAGE,
|
|
model: "anthropic/claude-sonnet-4-5",
|
|
},
|
|
},
|
|
{ provider: "anthropic", model: "claude-sonnet-4-5" },
|
|
);
|
|
});
|
|
});
|
|
});
|