mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 17:00:59 +00:00
- Fix ai/CHANGELOG.md: add PR link and author attribution - Add coding-agent/CHANGELOG.md entry for vercel-ai-gateway provider - Fix model-resolver.test.ts: use anthropic-messages API type to match generated models - Add vercel-ai-gateway to test suites: tokens, abort, empty, context-overflow, unicode-surrogate, tool-call-without-result, image-tool-result, total-tokens, image-limits
236 lines
8.7 KiB
TypeScript
236 lines
8.7 KiB
TypeScript
import type { Model } from "@mariozechner/pi-ai";
|
|
import { describe, expect, test } from "vitest";
|
|
import { defaultModelPerProvider, findInitialModel, parseModelPattern } from "../src/core/model-resolver.js";
|
|
|
|
// 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 allModels = [...mockModels, ...mockOpenRouterModels];
|
|
|
|
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.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.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.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", () => {
|
|
for (const level of ["off", "minimal", "low", "medium", "high", "xhigh"]) {
|
|
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.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.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.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.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.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.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.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.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.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();
|
|
});
|
|
|
|
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("default model selection", () => {
|
|
test("ai-gateway default is opus 4.5", () => {
|
|
expect(defaultModelPerProvider["vercel-ai-gateway"]).toBe("anthropic/claude-opus-4.5");
|
|
});
|
|
|
|
test("findInitialModel selects ai-gateway default when available", async () => {
|
|
const aiGatewayModel: Model<"anthropic-messages"> = {
|
|
id: "anthropic/claude-opus-4.5",
|
|
name: "Claude Opus 4.5",
|
|
api: "anthropic-messages",
|
|
provider: "vercel-ai-gateway",
|
|
baseUrl: "https://ai-gateway.vercel.sh",
|
|
reasoning: true,
|
|
input: ["text", "image"],
|
|
cost: { input: 5, output: 15, cacheRead: 0.5, cacheWrite: 5 },
|
|
contextWindow: 200000,
|
|
maxTokens: 8192,
|
|
};
|
|
|
|
const registry = {
|
|
getAvailable: async () => [aiGatewayModel],
|
|
} as unknown as Parameters<typeof findInitialModel>[0]["modelRegistry"];
|
|
|
|
const result = await findInitialModel({
|
|
scopedModels: [],
|
|
isContinuing: false,
|
|
modelRegistry: registry,
|
|
});
|
|
|
|
expect(result.model?.provider).toBe("vercel-ai-gateway");
|
|
expect(result.model?.id).toBe("anthropic/claude-opus-4.5");
|
|
});
|
|
});
|