8e3e0ebf9e
- Introduced Effort enum and ThinkingConfig metadata for per-model reasoning capabilities with min/max effort levels. - Migrated thinking level API from string-based ThinkingLevel to structured Effort enum across agent and AI packages. - Added model-thinking module with effort mapping, policy application, and semantic versioning utilities for provider-specific thinking modes. - Removed supportsXhigh() function and replaced effort clamping with model-aware validation using ThinkingConfig metadata. - Expanded models.json with thinking configuration objects for 50+ models including Claude, Gemini, and OpenAI variants. - Added Python analysis scripts for edit tool usage patterns and tool invocation stream processing.
579 lines
20 KiB
TypeScript
579 lines
20 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { Effort, type Model } from "@oh-my-pi/pi-ai";
|
|
import {
|
|
parseModelPattern,
|
|
parseModelString,
|
|
resolveCliModel,
|
|
resolveModelFromString,
|
|
resolveModelOverride,
|
|
resolveModelRoleValue,
|
|
} 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,
|
|
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,
|
|
},
|
|
{
|
|
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,
|
|
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,
|
|
},
|
|
{
|
|
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 mockCodexOverlapModels: Model<"anthropic-messages">[] = [
|
|
{
|
|
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,
|
|
},
|
|
{
|
|
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,
|
|
},
|
|
];
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
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("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("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 --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("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");
|
|
});
|
|
});
|
|
|
|
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:" });
|
|
});
|
|
});
|
|
});
|