Files
oh-my-pi/packages/coding-agent/test/model-resolver.test.ts
T
roboomp fa613d438d fix(agent): inherited default for unset designer role
Unconfigured pi/designer now follows modelRoles.default before consulting the Gemini priority chain, matching the smol and slow fallback behavior while preserving priority defaults when no default is configured.

Added regression coverage and updated the coding-agent changelog.

Fixes #2336
2026-06-11 20:40:08 +00:00

1167 lines
42 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { type Api, Effort, type Model } from "@oh-my-pi/pi-ai";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import type { CanonicalModelVariant } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import {
expandRoleAlias,
filterAvailableModelsByEnabledPatterns,
parseModelPattern,
parseModelString,
resolveAgentModelPatterns,
resolveCliModel,
resolveModelFromString,
resolveModelOverride,
resolveModelRoleValue,
resolveModelScope,
} from "@oh-my-pi/pi-coding-agent/config/model-resolver";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
// Mock models for testing
const mockModels: Model<"anthropic-messages">[] = [
buildModel({
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "https://api.anthropic.com",
reasoning: true,
thinking: {
mode: "budget",
efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High],
},
input: ["text", "image"],
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
contextWindow: 200000,
maxTokens: 8192,
}),
buildModel({
id: "gpt-4o",
name: "GPT-4o",
api: "anthropic-messages", // Using same type for simplicity
provider: "openai",
baseUrl: "https://api.openai.com",
reasoning: false,
input: ["text", "image"],
cost: { input: 5, output: 15, cacheRead: 0.5, cacheWrite: 5 },
contextWindow: 128000,
maxTokens: 4096,
}),
];
// Mock OpenRouter models with colons in IDs
const mockOpenRouterModels: Model<Api>[] = [
buildModel({
id: "qwen/qwen3-coder:exacto",
name: "Qwen3 Coder Exacto",
api: "anthropic-messages",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: true,
thinking: {
mode: "budget",
efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High],
},
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 },
contextWindow: 128000,
maxTokens: 8192,
}),
buildModel({
id: "openai/gpt-4o:extended",
name: "GPT-4o Extended",
api: "anthropic-messages",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text", "image"],
cost: { input: 5, output: 15, cacheRead: 0.5, cacheWrite: 5 },
contextWindow: 128000,
maxTokens: 4096,
}),
buildModel({
id: "z-ai/glm-4.7",
name: "GLM 4.7",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: true,
thinking: {
mode: "budget",
efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High],
},
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 },
contextWindow: 128000,
maxTokens: 8192,
}),
];
const mockProviderOverlapModels: Model<"anthropic-messages">[] = [
buildModel({
id: "kimi-k2.5",
name: "Kimi K2.5",
api: "anthropic-messages",
provider: "kimi-code",
baseUrl: "https://api.kimi.ai",
reasoning: false,
input: ["text"],
cost: { input: 2, output: 6, cacheRead: 0.2, cacheWrite: 2 },
contextWindow: 128000,
maxTokens: 8192,
}),
buildModel({
id: "moonshotai/kimi-k2.5",
name: "Kimi K2.5 (OpenRouter)",
api: "anthropic-messages",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: { input: 2.2, output: 6.2, cacheRead: 0.22, cacheWrite: 2.2 },
contextWindow: 128000,
maxTokens: 8192,
}),
];
const mockCodexOverlapModels: Model<"anthropic-messages">[] = [
buildModel({
id: "gpt-5.3-codex",
name: "GPT-5.3 Codex",
api: "anthropic-messages",
provider: "openai-codex",
baseUrl: "https://api.openai.com",
reasoning: true,
thinking: {
mode: "effort",
efforts: [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
},
input: ["text"],
cost: { input: 1.5, output: 6, cacheRead: 0.15, cacheWrite: 1.5 },
contextWindow: 200000,
maxTokens: 8192,
}),
buildModel({
id: "gpt-5.3-codex-spark",
name: "GPT-5.3 Codex Spark",
api: "anthropic-messages",
provider: "openai-codex",
baseUrl: "https://api.openai.com",
reasoning: true,
thinking: {
mode: "effort",
efforts: [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
},
input: ["text"],
cost: { input: 1, output: 4, cacheRead: 0.1, cacheWrite: 1 },
contextWindow: 200000,
maxTokens: 8192,
}),
];
function createOpusModel(provider: string, id: string, name: string): Model<"anthropic-messages"> {
return buildModel({
id,
name,
api: "anthropic-messages",
provider,
baseUrl: provider === "anthropic" ? "https://api.anthropic.com" : "https://api.githubcopilot.com",
reasoning: true,
thinking: {
mode: "budget",
efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh],
},
input: ["text", "image"],
cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
contextWindow: 200000,
maxTokens: 32000,
});
}
const canonicalVariantModels: Model<"anthropic-messages">[] = [
buildModel({
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "https://api.anthropic.com",
reasoning: true,
thinking: {
mode: "budget",
efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High],
},
input: ["text", "image"],
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
contextWindow: 200000,
maxTokens: 8192,
}),
buildModel({
id: "anthropic/claude-sonnet-4.5",
name: "Claude Sonnet 4.5 (Copilot)",
api: "anthropic-messages",
provider: "github-copilot",
baseUrl: "https://api.githubcopilot.com",
reasoning: true,
thinking: {
mode: "budget",
efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High],
},
input: ["text", "image"],
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
contextWindow: 200000,
maxTokens: 8192,
}),
];
const canonicalRegistry = {
resolveCanonicalModel: (canonicalId: string, options?: { candidates?: Model<"anthropic-messages">[] }) => {
if (canonicalId !== "claude-sonnet-4-5") return undefined;
const candidates = options?.candidates ?? canonicalVariantModels;
return (
candidates.find(model => model.provider === "github-copilot") ??
candidates.find(model => model.provider === "anthropic")
);
},
getCanonicalVariants: (canonicalId: string, options?: { candidates?: Model<"anthropic-messages">[] }) => {
if (canonicalId !== "claude-sonnet-4-5") return [];
const candidates = options?.candidates ?? canonicalVariantModels;
return candidates.map(model => ({
canonicalId,
selector: `${model.provider}/${model.id}`,
model,
source: model.id === canonicalId ? "bundled" : "heuristic",
}));
},
getCanonicalId: () => "claude-sonnet-4-5",
getAvailable: () => canonicalVariantModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
const allModels = [...mockModels, ...mockOpenRouterModels, ...mockProviderOverlapModels, ...mockCodexOverlapModels];
describe("parseModelPattern", () => {
describe("simple patterns without colons", () => {
test("exact match returns model with undefined thinking level", () => {
const result = parseModelPattern("claude-sonnet-4-5", allModels);
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.thinkingLevel).toBeUndefined();
expect(result.explicitThinkingLevel).toBe(false);
expect(result.warning).toBeUndefined();
});
test("partial match returns best model with undefined thinking level", () => {
const result = parseModelPattern("sonnet", allModels);
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.thinkingLevel).toBeUndefined();
expect(result.explicitThinkingLevel).toBe(false);
expect(result.warning).toBeUndefined();
});
test("no match returns undefined model and thinking level", () => {
const result = parseModelPattern("nonexistent", allModels);
expect(result.model).toBeUndefined();
expect(result.thinkingLevel).toBeUndefined();
expect(result.explicitThinkingLevel).toBe(false);
expect(result.warning).toBeUndefined();
});
});
describe("patterns with valid thinking levels", () => {
test("sonnet:high returns sonnet with high thinking level", () => {
const result = parseModelPattern("sonnet:high", allModels);
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.thinkingLevel).toBe(Effort.High);
expect(result.warning).toBeUndefined();
});
test("gpt-4o:medium returns gpt-4o with medium thinking level", () => {
const result = parseModelPattern("gpt-4o:medium", allModels);
expect(result.model?.id).toBe("gpt-4o");
expect(result.thinkingLevel).toBe(Effort.Medium);
expect(result.warning).toBeUndefined();
});
test("all valid thinking levels work", () => {
const levels = ["off", Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh] as const;
for (const level of levels) {
const result = parseModelPattern(`sonnet:${level}`, allModels);
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.thinkingLevel).toBe(level);
expect(result.warning).toBeUndefined();
}
});
});
describe("patterns with invalid thinking levels", () => {
test("sonnet:random returns sonnet with undefined thinking level and warning", () => {
const result = parseModelPattern("sonnet:random", allModels);
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.thinkingLevel).toBeUndefined();
expect(result.explicitThinkingLevel).toBe(false);
expect(result.warning).toContain("Invalid thinking level");
expect(result.warning).toContain("random");
});
test("gpt-4o:invalid returns gpt-4o with undefined thinking level and warning", () => {
const result = parseModelPattern("gpt-4o:invalid", allModels);
expect(result.model?.id).toBe("gpt-4o");
expect(result.thinkingLevel).toBeUndefined();
expect(result.explicitThinkingLevel).toBe(false);
expect(result.warning).toContain("Invalid thinking level");
});
});
describe("OpenRouter models with colons in IDs", () => {
test("qwen3-coder:exacto matches the model with undefined thinking level", () => {
const result = parseModelPattern("qwen/qwen3-coder:exacto", allModels);
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
expect(result.thinkingLevel).toBeUndefined();
expect(result.explicitThinkingLevel).toBe(false);
expect(result.warning).toBeUndefined();
});
test("openrouter/qwen/qwen3-coder:exacto matches with provider prefix", () => {
const result = parseModelPattern("openrouter/qwen/qwen3-coder:exacto", allModels);
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
expect(result.model?.provider).toBe("openrouter");
expect(result.thinkingLevel).toBeUndefined();
expect(result.explicitThinkingLevel).toBe(false);
expect(result.warning).toBeUndefined();
});
test("qwen3-coder:exacto:high matches model with high thinking level", () => {
const result = parseModelPattern("qwen/qwen3-coder:exacto:high", allModels);
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
expect(result.thinkingLevel).toBe(Effort.High);
expect(result.explicitThinkingLevel).toBe(true);
expect(result.warning).toBeUndefined();
});
test("openrouter/qwen/qwen3-coder:exacto:high matches with provider and thinking level", () => {
const result = parseModelPattern("openrouter/qwen/qwen3-coder:exacto:high", allModels);
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
expect(result.model?.provider).toBe("openrouter");
expect(result.thinkingLevel).toBe(Effort.High);
expect(result.explicitThinkingLevel).toBe(true);
expect(result.warning).toBeUndefined();
});
test("gpt-4o:extended matches the extended model with undefined thinking level", () => {
const result = parseModelPattern("openai/gpt-4o:extended", allModels);
expect(result.model?.id).toBe("openai/gpt-4o:extended");
expect(result.thinkingLevel).toBeUndefined();
expect(result.explicitThinkingLevel).toBe(false);
expect(result.warning).toBeUndefined();
});
test("supports OpenRouter route suffixes that are not present in the catalog", () => {
const result = parseModelPattern("openrouter/z-ai/glm-4.7-20251222:nitro", allModels);
expect(result.model?.provider).toBe("openrouter");
expect(result.model?.id).toBe("z-ai/glm-4.7-20251222:nitro");
expect(result.thinkingLevel).toBeUndefined();
expect(result.explicitThinkingLevel).toBe(false);
expect(result.warning).toBeUndefined();
});
test("supports OpenRouter route suffixes with an appended thinking level", () => {
const result = parseModelPattern("openrouter/z-ai/glm-4.7-20251222:nitro:high", allModels);
expect(result.model?.provider).toBe("openrouter");
expect(result.model?.id).toBe("z-ai/glm-4.7-20251222:nitro");
expect(result.thinkingLevel).toBe(Effort.High);
expect(result.explicitThinkingLevel).toBe(true);
expect(result.warning).toBeUndefined();
});
});
describe("invalid thinking levels with OpenRouter models", () => {
test("qwen3-coder:exacto:random returns model with undefined thinking level and warning", () => {
const result = parseModelPattern("qwen/qwen3-coder:exacto:random", allModels);
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
expect(result.thinkingLevel).toBeUndefined();
expect(result.explicitThinkingLevel).toBe(false);
expect(result.warning).toContain("Invalid thinking level");
expect(result.warning).toContain("random");
});
test("qwen3-coder:exacto:high:random returns model with undefined thinking level and warning", () => {
const result = parseModelPattern("qwen/qwen3-coder:exacto:high:random", allModels);
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
expect(result.thinkingLevel).toBeUndefined();
expect(result.explicitThinkingLevel).toBe(false);
expect(result.warning).toContain("Invalid thinking level");
expect(result.warning).toContain("random");
});
});
describe("edge cases", () => {
test("empty pattern matches via partial matching", () => {
// Empty string is included in all model IDs, so partial matching finds a match
const result = parseModelPattern("", allModels);
expect(result.model).not.toBeNull();
expect(result.thinkingLevel).toBeUndefined();
expect(result.explicitThinkingLevel).toBe(false);
});
test("pattern ending with colon treats empty suffix as invalid", () => {
const result = parseModelPattern("sonnet:", allModels);
// Empty string after colon is not a valid thinking level
// So it tries to match "sonnet:" which won't match, then tries "sonnet"
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.warning).toContain("Invalid thinking level");
});
});
describe("preference logic", () => {
test("prefers most recently used model when multiple providers match", () => {
const result = parseModelPattern("k2.5", allModels, {
usageOrder: ["kimi-code/kimi-k2.5"],
});
expect(result.model?.provider).toBe("kimi-code");
});
test("prefers first-party providers over OpenRouter when no usage data exists", () => {
const result = parseModelPattern("k2.5", allModels, { usageOrder: [] });
expect(result.model?.provider).toBe("kimi-code");
});
test("respects most recently used provider even if openrouter", () => {
const result = parseModelPattern("k2.5", allModels, {
usageOrder: ["openrouter/moonshotai/kimi-k2.5"],
});
expect(result.model?.provider).toBe("openrouter");
expect(result.model?.id).toBe("moonshotai/kimi-k2.5");
});
});
describe("canonical ids", () => {
test("resolves an exact canonical id through the registry before bare-id matching", () => {
const result = parseModelPattern("claude-sonnet-4-5", canonicalVariantModels, undefined, {
modelRegistry: canonicalRegistry,
});
expect(result.model?.provider).toBe("github-copilot");
expect(result.model?.id).toBe("anthropic/claude-sonnet-4.5");
});
});
});
describe("resolveModelRoleValue", () => {
test("resolves pi/<role>:<thinking> by expanding role alias before parsing thinking", () => {
const settings = {
getModelRole: (role: string) => (role === "smol" ? "openrouter/qwen/qwen3-coder:exacto" : undefined),
} as NonNullable<Parameters<typeof resolveModelRoleValue>[2]>["settings"];
const result = resolveModelRoleValue("pi/smol:high", allModels, { settings });
expect(result.model?.provider).toBe("openrouter");
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
expect(result.thinkingLevel).toBe(Effort.High);
expect(result.explicitThinkingLevel).toBe(true);
});
test("resolves pi/default through configured default role alias", () => {
const settings = {
getModelRole: (role: string) => (role === "default" ? "openrouter/qwen/qwen3-coder:exacto" : undefined),
} as NonNullable<Parameters<typeof resolveModelRoleValue>[2]>["settings"];
const result = resolveModelRoleValue("pi/default", allModels, { settings });
expect(result.model?.provider).toBe("openrouter");
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
expect(result.thinkingLevel).toBeUndefined();
expect(result.explicitThinkingLevel).toBe(false);
expect(result.warning).toBeUndefined();
});
test("splits direct comma fallback chains before parsing thinking selectors", () => {
const result = resolveModelRoleValue("anthropic/claude-sonnet-4-5:off,openai/gpt-4o:off", allModels);
expect(result.model?.provider).toBe("anthropic");
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.thinkingLevel).toBe("off");
expect(result.explicitThinkingLevel).toBe(true);
expect(result.warning).toBeUndefined();
});
test("tries later direct comma fallback entries when earlier entries miss", () => {
const result = resolveModelRoleValue("anthropic/missing:off,openai/gpt-4o:off", allModels);
expect(result.model?.provider).toBe("openai");
expect(result.model?.id).toBe("gpt-4o");
expect(result.thinkingLevel).toBe("off");
expect(result.explicitThinkingLevel).toBe(true);
expect(result.warning).toBeUndefined();
});
test("does not resolve exact codex role values to codex-spark via substring matching", () => {
const providerQualified = resolveModelRoleValue("openai-codex/gpt-5.3-codex:xhigh", allModels);
expect(providerQualified.model?.provider).toBe("openai-codex");
expect(providerQualified.model?.id).toBe("gpt-5.3-codex");
expect(providerQualified.thinkingLevel).toBe(Effort.XHigh);
expect(providerQualified.explicitThinkingLevel).toBe(true);
const idOnly = resolveModelRoleValue("gpt-5.3-codex:xhigh", allModels);
expect(idOnly.model?.provider).toBe("openai-codex");
expect(idOnly.model?.id).toBe("gpt-5.3-codex");
expect(idOnly.thinkingLevel).toBe(Effort.XHigh);
expect(idOnly.explicitThinkingLevel).toBe(true);
});
test("clamps explicit thinking selectors from model metadata", () => {
const result = resolveModelRoleValue("anthropic/claude-sonnet-4-5:xhigh", allModels);
expect(result.model?.provider).toBe("anthropic");
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.thinkingLevel).toBe(Effort.High);
expect(result.explicitThinkingLevel).toBe(true);
});
});
describe("resolveAgentModelPatterns", () => {
test("falls back to the active session model when pi/task is unset", () => {
const settings = Settings.isolated({
modelRoles: { default: "anthropic/claude-sonnet-4-5" },
});
const result = resolveAgentModelPatterns({
agentModel: "pi/task",
settings,
activeModelPattern: "openai/gpt-4o",
});
expect(result).toEqual(["openai/gpt-4o"]);
});
test("uses the configured task role before falling back to the session model", () => {
const settings = Settings.isolated({
modelRoles: {
default: "openai/gpt-4o",
task: "anthropic/claude-sonnet-4-5:high",
},
});
const result = resolveAgentModelPatterns({
agentModel: "pi/task",
settings,
activeModelPattern: "openai/gpt-4o",
});
expect(result).toEqual(["anthropic/claude-sonnet-4-5:high"]);
});
test("uses default for unconfigured smol, slow, and designer agent roles before priority defaults", () => {
const settings = Settings.isolated({
modelRoles: { default: "local/llama" },
});
expect(resolveAgentModelPatterns({ agentModel: "pi/smol", settings })).toEqual(["local/llama"]);
expect(resolveAgentModelPatterns({ agentModel: "pi/slow", settings })).toEqual(["local/llama"]);
expect(resolveAgentModelPatterns({ agentModel: "pi/designer", settings })).toEqual(["local/llama"]);
});
test("keeps built-in priority defaults when default aliases the same unset role", () => {
const smolSettings = Settings.isolated({
modelRoles: { default: "pi/smol" },
});
const slowSettings = Settings.isolated({
modelRoles: { default: "pi/slow" },
});
const designerSettings = Settings.isolated({
modelRoles: { default: "pi/designer" },
});
expect(resolveAgentModelPatterns({ agentModel: "pi/smol", settings: smolSettings })).toEqual([
"cerebras/zai-glm-4.7",
"cerebras/zai-glm-4.6",
"cerebras/zai-glm",
"haiku-4-5",
"haiku-4.5",
"haiku",
"flash",
"mini",
]);
expect(resolveAgentModelPatterns({ agentModel: "pi/slow", settings: slowSettings })[0]).toBe("gpt-5.4");
expect(resolveAgentModelPatterns({ agentModel: "pi/designer", settings: designerSettings })[0]).toBe(
"google-gemini-cli/gemini-3.1-pro",
);
});
test("expands cross-role default aliases when inheriting for an unset role", () => {
const settings = Settings.isolated({
modelRoles: { default: "pi/slow", slow: "anthropic/claude-sonnet-4-5" },
});
expect(resolveAgentModelPatterns({ agentModel: "pi/smol", settings })).toEqual(["anthropic/claude-sonnet-4-5"]);
});
test("recurses into priority defaults when default points at another unset role", () => {
const settings = Settings.isolated({
modelRoles: { default: "pi/slow" },
});
expect(resolveAgentModelPatterns({ agentModel: "pi/smol", settings })[0]).toBe("gpt-5.4");
});
test("expands pi/designer to priority defaults when default is unset", () => {
const settings = Settings.isolated();
const result = resolveAgentModelPatterns({
agentModel: "pi/designer",
settings,
});
expect(result).toEqual([
"google-gemini-cli/gemini-3.1-pro",
"google-gemini-cli/gemini-3-pro",
"gemini-3.1-pro",
"gemini-3-1-pro",
"gemini-3-pro",
"gemini-3",
]);
});
test("prefers configured designer role override over priority defaults", () => {
const settings = Settings.isolated({
modelRoles: {
default: "anthropic/claude-sonnet-4-5",
designer: "openai/gpt-4o",
},
});
const result = resolveAgentModelPatterns({
agentModel: "pi/designer",
settings,
});
expect(result).toEqual(["openai/gpt-4o"]);
});
test("slow priority falls forward to Opus 4.8 before older Opus aliases", () => {
const settings = Settings.isolated();
const patterns = resolveAgentModelPatterns({ agentModel: "pi/slow", settings });
const dottedRegistry = {
getAvailable: () => [
createOpusModel("github-copilot", "claude-opus-4.7", "Claude Opus 4.7"),
createOpusModel("github-copilot", "claude-opus-4.8", "Claude Opus 4.8"),
],
} as Parameters<typeof resolveModelOverride>[1];
const dotted = resolveModelOverride(patterns, dottedRegistry, settings);
expect(dotted.model?.provider).toBe("github-copilot");
expect(dotted.model?.id).toBe("claude-opus-4.8");
const dashedRegistry = {
getAvailable: () => [
createOpusModel("anthropic", "claude-opus-4-7", "Claude Opus 4.7"),
createOpusModel("anthropic", "claude-opus-4-8", "Claude Opus 4.8"),
],
} as Parameters<typeof resolveModelOverride>[1];
const dashed = resolveModelOverride(patterns, dashedRegistry, settings);
expect(dashed.model?.provider).toBe("anthropic");
expect(dashed.model?.id).toBe("claude-opus-4-8");
});
});
describe("resolveModelFromString", () => {
test("falls back to pattern parsing for provider/model:thinking when strict provider+id miss", () => {
const resolved = resolveModelFromString("openrouter/qwen/qwen3-coder:exacto:high", allModels);
expect(resolved?.provider).toBe("openrouter");
expect(resolved?.id).toBe("qwen/qwen3-coder:exacto");
});
test("treats colon-containing model IDs without thinking suffix as exact IDs", () => {
const resolved = resolveModelFromString("openrouter/qwen/qwen3-coder:exacto", allModels);
expect(resolved?.provider).toBe("openrouter");
expect(resolved?.id).toBe("qwen/qwen3-coder:exacto");
});
});
describe("resolveModelOverride", () => {
test("preserves explicit off and explicit-thinking metadata", () => {
const registry = {
getAvailable: () => allModels,
} as Parameters<typeof resolveModelOverride>[1];
const result = resolveModelOverride(["sonnet:off"], registry);
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.thinkingLevel).toBe("off");
expect(result.explicitThinkingLevel).toBe(true);
});
test("resolves colon-containing model IDs with appended thinking suffix", () => {
const registry = {
getAvailable: () => allModels,
} as Parameters<typeof resolveModelOverride>[1];
const result = resolveModelOverride(["openrouter/qwen/qwen3-coder:exacto:high"], registry);
expect(result.model?.provider).toBe("openrouter");
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
expect(result.thinkingLevel).toBe(Effort.High);
expect(result.explicitThinkingLevel).toBe(true);
});
});
describe("resolveCliModel", () => {
test("resolves exact canonical ids to the preferred concrete provider", () => {
const result = resolveCliModel({
cliModel: "claude-sonnet-4-5",
modelRegistry: {
...canonicalRegistry,
getAll: () => canonicalVariantModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"],
});
expect(result.error).toBeUndefined();
expect(result.selector).toBe("claude-sonnet-4-5");
expect(result.model?.provider).toBe("github-copilot");
expect(result.model?.id).toBe("anthropic/claude-sonnet-4.5");
});
test("resolves --model provider/id without --provider", () => {
const registry = {
getAll: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
const result = resolveCliModel({
cliModel: "openai/gpt-4o",
modelRegistry: registry,
});
expect(result.error).toBeUndefined();
expect(result.model?.provider).toBe("openai");
expect(result.model?.id).toBe("gpt-4o");
});
test("resolves fuzzy patterns within an explicit provider", () => {
const registry = {
getAll: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
const result = resolveCliModel({
cliProvider: "openai",
cliModel: "4o",
modelRegistry: registry,
});
expect(result.error).toBeUndefined();
expect(result.model?.provider).toBe("openai");
expect(result.model?.id).toBe("gpt-4o");
});
test("supports --model <pattern>:<thinking> (without explicit --thinking)", () => {
const registry = {
getAll: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
const result = resolveCliModel({
cliModel: "sonnet:high",
modelRegistry: registry,
});
expect(result.error).toBeUndefined();
expect(result.model?.id).toBe("claude-sonnet-4-5");
expect(result.thinkingLevel).toBe(Effort.High);
});
test("prefers exact model id match over provider inference (OpenRouter-style ids)", () => {
const registry = {
getAll: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
const result = resolveCliModel({
cliModel: "openai/gpt-4o:extended",
modelRegistry: registry,
});
expect(result.error).toBeUndefined();
expect(result.model?.provider).toBe("openrouter");
expect(result.model?.id).toBe("openai/gpt-4o:extended");
});
test("does not strip invalid :suffix as thinking level in --model (fail fast)", () => {
const registry = {
getAll: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
const result = resolveCliModel({
cliProvider: "openai",
cliModel: "gpt-4o:extended",
modelRegistry: registry,
});
expect(result.model).toBeUndefined();
expect(result.error).toContain("not found");
});
test("supports provider-prefixed OpenRouter route suffixes even when the base model is cataloged without them", () => {
const registry = {
getAll: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
const result = resolveCliModel({
cliModel: "openrouter/z-ai/glm-4.7-20251222:nitro",
modelRegistry: registry,
});
expect(result.error).toBeUndefined();
expect(result.model?.provider).toBe("openrouter");
expect(result.model?.id).toBe("z-ai/glm-4.7-20251222:nitro");
});
test("supports explicit OpenRouter provider with route suffixes that are not in the catalog", () => {
const registry = {
getAll: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
const result = resolveCliModel({
cliProvider: "openrouter",
cliModel: "z-ai/glm-4.7-20251222:nitro",
modelRegistry: registry,
});
expect(result.error).toBeUndefined();
expect(result.model?.provider).toBe("openrouter");
expect(result.model?.id).toBe("z-ai/glm-4.7-20251222:nitro");
});
test("returns a clear error when there are no models", () => {
const registry = {
getAll: () => [],
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
const result = resolveCliModel({
cliProvider: "openai",
cliModel: "gpt-4o",
modelRegistry: registry,
});
expect(result.model).toBeUndefined();
expect(result.error).toContain("No models available");
});
test("resolves provider-prefixed fuzzy patterns (openrouter/qwen -> openrouter model)", () => {
const registry = {
getAll: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
const result = resolveCliModel({
cliModel: "openrouter/qwen",
modelRegistry: registry,
});
expect(result.error).toBeUndefined();
expect(result.model?.provider).toBe("openrouter");
expect(result.model?.id).toBe("qwen/qwen3-coder:exacto");
});
test("prefers decomposed provider+id over flat id match when ambiguous", () => {
// Simulates the zai/glm-5 bug: vercel-ai-gateway has id="zai/glm-5",
// zai has id="glm-5". Input "zai/glm-5" should resolve to provider=zai.
const ambiguousModels: Model<"anthropic-messages">[] = [
buildModel({
id: "zai/glm-5",
name: "GLM-5 (Vercel)",
api: "anthropic-messages",
provider: "vercel-ai-gateway",
baseUrl: "https://vercel.ai",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 },
contextWindow: 128000,
maxTokens: 4096,
}),
buildModel({
id: "glm-5",
name: "GLM-5",
api: "anthropic-messages",
provider: "zai",
baseUrl: "https://api.z.ai",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 },
contextWindow: 128000,
maxTokens: 4096,
}),
];
const registry = {
getAll: () => ambiguousModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
const result = resolveCliModel({
cliModel: "zai/glm-5",
modelRegistry: registry,
});
expect(result.error).toBeUndefined();
expect(result.model?.provider).toBe("zai");
expect(result.model?.id).toBe("glm-5");
});
});
describe("resolveModelScope", () => {
test("expands exact canonical ids into all concrete variants", async () => {
const scoped = await resolveModelScope(["claude-sonnet-4-5"], {
getAvailable: () => canonicalVariantModels,
getCanonicalVariants: (canonicalId: string, options?: { candidates?: Model<"anthropic-messages">[] }) =>
canonicalRegistry.getCanonicalVariants!(canonicalId, options),
} as unknown as Parameters<typeof resolveModelScope>[1]);
expect(scoped).toHaveLength(2);
expect(scoped.map(entry => `${entry.model.provider}/${entry.model.id}`).sort()).toEqual([
"anthropic/claude-sonnet-4-5",
"github-copilot/anthropic/claude-sonnet-4.5",
]);
});
});
describe("parseModelString", () => {
test("parses standard provider/id format", () => {
const result = parseModelString("anthropic/claude-sonnet-4-5");
expect(result).toEqual({ provider: "anthropic", id: "claude-sonnet-4-5" });
});
test("returns undefined for strings without a slash", () => {
expect(parseModelString("claude-sonnet-4-5")).toBeUndefined();
expect(parseModelString("")).toBeUndefined();
expect(parseModelString("sonnet:high")).toBeUndefined();
});
test("returns undefined for strings starting with slash", () => {
expect(parseModelString("/claude-sonnet-4-5")).toBeUndefined();
});
describe("thinking level suffix extraction", () => {
test("extracts valid thinking level from provider/id:level", () => {
const result = parseModelString("anthropic/claude-sonnet-4-5:high");
expect(result).toEqual({ provider: "anthropic", id: "claude-sonnet-4-5", thinkingLevel: Effort.High });
});
test("extracts all valid thinking levels", () => {
const levels = ["off", Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh] as const;
for (const level of levels) {
const result = parseModelString(`anthropic/claude-sonnet-4-5:${level}`);
expect(result?.id).toBe("claude-sonnet-4-5");
expect(result?.thinkingLevel).toBe(level);
}
});
test("does NOT strip invalid suffix — treats it as part of model ID", () => {
const result = parseModelString("openrouter/qwen/qwen3-coder:exacto");
expect(result).toEqual({ provider: "openrouter", id: "qwen/qwen3-coder:exacto" });
});
test("handles model ID with colon followed by valid thinking level", () => {
// e.g. "openrouter/qwen/qwen3-coder:exacto:high" — last colon is thinking level
const result = parseModelString("openrouter/qwen/qwen3-coder:exacto:high");
expect(result).toEqual({
provider: "openrouter",
id: "qwen/qwen3-coder:exacto",
thinkingLevel: Effort.High,
});
});
test("does not extract thinking level from model ID with invalid suffix", () => {
const result = parseModelString("openrouter/openai/gpt-4o:extended");
// :extended is not a valid thinking level, so it stays as part of the ID
expect(result).toEqual({ provider: "openrouter", id: "openai/gpt-4o:extended" });
});
test("handles empty suffix after colon", () => {
const result = parseModelString("anthropic/claude-sonnet-4-5:");
// Empty string is not a valid thinking level, so colon stays as part of ID
expect(result).toEqual({ provider: "anthropic", id: "claude-sonnet-4-5:" });
});
});
});
describe("expandRoleAlias", () => {
test("expands pi/vision to configured vision role", () => {
const settings = Settings.isolated();
settings.setModelRole("vision", "openai/gpt-4o");
expect(expandRoleAlias("pi/vision", settings)).toBe("openai/gpt-4o");
});
test("keeps pi/vision alias when vision role is unset", () => {
const settings = Settings.isolated();
settings.setModelRole("default", "anthropic/claude-sonnet-4-5");
expect(expandRoleAlias("pi/vision", settings)).toBe("pi/vision");
});
});
describe("provider routing selector (@upstream)", () => {
const openRouterOnly = (model: Model<Api> | undefined): string[] | undefined =>
(model?.compat as { openRouterRouting?: { only?: string[] } } | undefined)?.openRouterRouting?.only;
test("pins an OpenRouter model to one upstream via @slug", () => {
const result = parseModelPattern("openrouter/z-ai/glm-4.7@cerebras", allModels);
expect(result.model?.id).toBe("z-ai/glm-4.7");
expect(result.model?.provider).toBe("openrouter");
expect(result.upstream).toBe("cerebras");
expect(openRouterOnly(result.model)).toEqual(["cerebras"]);
});
test("resolves @slug without an explicit provider prefix", () => {
const result = parseModelPattern("z-ai/glm-4.7@cerebras", allModels);
expect(result.model?.id).toBe("z-ai/glm-4.7");
expect(openRouterOnly(result.model)).toEqual(["cerebras"]);
});
test("combines @slug with a trailing thinking level", () => {
const result = parseModelPattern("openrouter/z-ai/glm-4.7@cerebras:high", allModels);
expect(result.model?.id).toBe("z-ai/glm-4.7");
expect(result.thinkingLevel).toBe(Effort.High);
expect(openRouterOnly(result.model)).toEqual(["cerebras"]);
});
test("routes Vercel AI Gateway models via vercelGatewayRouting", () => {
const gatewayModel: Model<"openai-completions"> = buildModel({
id: "zai/glm-4.7",
name: "GLM 4.7 (Gateway)",
api: "openai-completions",
provider: "vercel-ai-gateway",
baseUrl: "https://ai-gateway.vercel.sh/v1",
reasoning: true,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 },
contextWindow: 128000,
maxTokens: 8192,
});
const result = parseModelPattern("vercel-ai-gateway/zai/glm-4.7@cerebras", [gatewayModel]);
expect(result.model?.id).toBe("zai/glm-4.7");
expect(
(result.model?.compat as { vercelGatewayRouting?: { only?: string[] } } | undefined)?.vercelGatewayRouting
?.only,
).toEqual(["cerebras"]);
expect(openRouterOnly(result.model)).toBeUndefined();
});
test("does not split a model id that legitimately ends in @ (Vertex)", () => {
const vertexModel: Model<"anthropic-messages"> = buildModel({
id: "claude-opus-4-8@default",
name: "Claude Opus 4.8",
api: "anthropic-messages",
provider: "google-vertex",
baseUrl: "https://us-aiplatform.googleapis.com",
reasoning: true,
input: ["text"],
cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
contextWindow: 200000,
maxTokens: 32000,
});
const result = parseModelPattern("claude-opus-4-8@default", [vertexModel]);
expect(result.model?.id).toBe("claude-opus-4-8@default");
expect(result.upstream).toBeUndefined();
expect(openRouterOnly(result.model)).toBeUndefined();
});
test("ignores @slug on a non-aggregator model (no silent routing)", () => {
const result = parseModelPattern("gpt-4o@cerebras", allModels);
expect(result.model).toBeUndefined();
});
test("resolveCliModel round-trips @upstream in the selector and carries compat", () => {
const registry = { getAll: () => allModels } as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
const result = resolveCliModel({ cliModel: "openrouter/z-ai/glm-4.7@cerebras", modelRegistry: registry });
expect(result.model?.id).toBe("z-ai/glm-4.7");
expect(result.selector).toBe("openrouter/z-ai/glm-4.7@cerebras");
expect(openRouterOnly(result.model)).toEqual(["cerebras"]);
});
});
describe("filterAvailableModelsByEnabledPatterns", () => {
const models = mockModels as Model[];
const registry = {
getCanonicalVariants: (_id: string, _opts?: unknown): CanonicalModelVariant[] => [],
};
test("returns all models when patterns is empty", () => {
expect(filterAvailableModelsByEnabledPatterns(models, [], registry)).toEqual(models);
});
test("filters by exact provider/modelId", () => {
const result = filterAvailableModelsByEnabledPatterns(models, ["anthropic/claude-sonnet-4-5"], registry);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("claude-sonnet-4-5");
});
test("filters by bare model id matching across providers", () => {
const result = filterAvailableModelsByEnabledPatterns(models, ["claude-sonnet-4-5"], registry);
expect(result).toHaveLength(1);
expect(result[0].provider).toBe("anthropic");
});
test("expands canonical id via registry", () => {
const canonicalRegistry = {
getCanonicalVariants: (id: string, _opts?: unknown): CanonicalModelVariant[] =>
id === "claude-sonnet-4-5"
? [
{
canonicalId: "claude-sonnet-4-5",
selector: "anthropic/claude-sonnet-4-5",
model: models[0],
source: "bundled",
},
]
: [],
};
const result = filterAvailableModelsByEnabledPatterns(models, ["claude-sonnet-4-5"], canonicalRegistry);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("claude-sonnet-4-5");
});
test("strips :thinkingLevel suffix before matching", () => {
const result = filterAvailableModelsByEnabledPatterns(models, ["anthropic/claude-sonnet-4-5:high"], registry);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("claude-sonnet-4-5");
});
test("preserves colon-bearing OpenRouter ids (suffix is not a thinking level)", () => {
const openRouterModels = mockOpenRouterModels as Model[];
const result = filterAvailableModelsByEnabledPatterns(
openRouterModels,
["openrouter/qwen/qwen3-coder:exacto"],
registry,
);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("qwen/qwen3-coder:exacto");
});
test("matches bare OpenRouter-style model id with slash but no provider prefix", () => {
const openRouterModels = mockOpenRouterModels as Model[];
const result = filterAvailableModelsByEnabledPatterns(openRouterModels, ["qwen/qwen3-coder:exacto"], registry);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("qwen/qwen3-coder:exacto");
expect(result[0].provider).toBe("openrouter");
});
test("evaluates glob patterns against provider/modelId", () => {
const result = filterAvailableModelsByEnabledPatterns(models, ["anthropic/*"], registry);
expect(result).toHaveLength(1);
expect(result[0].provider).toBe("anthropic");
});
test("evaluates glob patterns against bare model id", () => {
const result = filterAvailableModelsByEnabledPatterns(models, ["claude-*"], registry);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("claude-sonnet-4-5");
});
test("applies glob and exact patterns together", () => {
const result = filterAvailableModelsByEnabledPatterns(models, ["anthropic/*", "openai/gpt-4o"], registry);
expect(result).toHaveLength(2);
});
test("returns empty list when no pattern matches (misconfiguration)", () => {
const result = filterAvailableModelsByEnabledPatterns(models, ["nonexistent-model"], registry);
expect(result).toHaveLength(0);
});
test("includes multiple patterns from different providers", () => {
const result = filterAvailableModelsByEnabledPatterns(
models,
["anthropic/claude-sonnet-4-5", "openai/gpt-4o"],
registry,
);
expect(result).toHaveLength(2);
});
});