Files
oh-my-pi/packages/coding-agent/test/model-resolver.test.ts
T
can1357 ae415199dc feat: added build-time compatibility in ModelSpec/buildModel pipeline
- Centralized catalog and registry handling on `ModelSpec` and `buildModel`, resolving compatibility at model build time.
- Removed runtime compatibility detectors and switched provider request flows to direct `model.compat` reads.
- Added compat fields (`supportsReasoningParams`, `alwaysSendMaxTokens`, `strictResponsesPairing`, `whenThinking`).
- Persisted explicit compatibility overrides through `compatConfig` in discovery and cache merge paths.
2026-06-10 06:20:51 +02:00

1006 lines
35 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 {
expandRoleAlias,
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",
minLevel: Effort.Minimal,
maxLevel: 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",
minLevel: Effort.Minimal,
maxLevel: 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",
minLevel: Effort.Minimal,
maxLevel: 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",
minLevel: Effort.Low,
maxLevel: 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",
minLevel: Effort.Low,
maxLevel: 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",
minLevel: Effort.Minimal,
maxLevel: 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",
minLevel: Effort.Minimal,
maxLevel: 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",
minLevel: Effort.Minimal,
maxLevel: 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("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("expands pi/designer to priority defaults", () => {
const settings = Settings.isolated({
modelRoles: {
default: "anthropic/claude-sonnet-4-5",
},
});
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"]);
});
});