Files
oh-my-pi/packages/coding-agent/test/model-resolver.test.ts
T
can1357 c12be01a5f fix(coding-agent): backported pi-mono changes (34878e..5133697)
packages/ai:
- fix: hardened OpenAI tool-call JSON parsing for malformed trailing arguments
- feat: routed GitHub Copilot Claude 4.x models through anthropic-messages
- feat: centralized dynamic Copilot headers and anthropic bearer auth handling
- feat: added optional StreamOptions.metadata propagation
- test: added Copilot headers/auth/routing coverage
- fix: updated model generator and models.json for Copilot Claude API mapping

packages/coding-agent:
- fix: made CLI model resolution deterministic with provider-aware pattern parsing
- fix: corrected compaction boundary/context usage handling after compaction
- feat: expanded extension events and terminal input hook integration
- fix: hardened git source parsing to avoid local-path misclassification
- test: added git-url parser coverage and model-resolver cases

packages/tui:
- fix: scoped @ fuzzy autocomplete to typed path prefixes
- feat: added Windows VT input mode support via bun:ffi

docs:
- chore: updated porting sync point to 5133697
2026-02-16 10:08:53 +01:00

374 lines
13 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import type { Model } from "@oh-my-pi/pi-ai";
import { parseModelPattern, resolveCliModel } from "@oh-my-pi/pi-coding-agent/config/model-resolver";
// Mock models for testing
const mockModels: Model<"anthropic-messages">[] = [
{
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "https://api.anthropic.com",
reasoning: true,
input: ["text", "image"],
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
contextWindow: 200000,
maxTokens: 8192,
},
{
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<"anthropic-messages">[] = [
{
id: "qwen/qwen3-coder:exacto",
name: "Qwen3 Coder Exacto",
api: "anthropic-messages",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: true,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 },
contextWindow: 128000,
maxTokens: 8192,
},
{
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,
},
];
const mockProviderOverlapModels: Model<"anthropic-messages">[] = [
{
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,
},
{
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 allModels = [...mockModels, ...mockOpenRouterModels, ...mockProviderOverlapModels];
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("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("medium");
expect(result.warning).toBeUndefined();
});
test("all valid thinking levels work", () => {
const levels = ["off", "minimal", "low", "medium", "high", "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("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("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();
});
});
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("falls back to deprioritizing openrouter when no usage data", () => {
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("resolveCliModel", () => {
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("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("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");
});
});