From 5dc3fd2ec0bd796ef0dc5d47462437121d6e6a71 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Mon, 23 Mar 2026 20:36:00 -0300 Subject: [PATCH] feat: per-model combo routing support (#563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add model-pattern → combo mapping feature that automatically routes requests to specific combos based on model name patterns (glob matching). Implementation: - New migration 010: model_combo_mappings table with pattern, combo_id, priority - DB module with CRUD + resolveComboForModel() using glob-to-regex matching - getComboForModel() in model.ts: augments getCombo() with pattern fallback - chat.ts: replaced getCombo() → getComboForModel() at routing decision point - API endpoints: GET/POST /api/model-combo-mappings, GET/PUT/DELETE by [id] - ModelRoutingSection.tsx: dashboard UI with inline add/edit/toggle/delete - Integrated into Combos page - 15 new unit tests (glob matching, priority ordering, disabled filtering) - Full test suite: 923/923 pass Examples: claude-sonnet* → code-combo claude-*-opus* → frontier-combo gpt-4o* → openai-combo gemini-* → google-combo Resolves: #563 --- src/app/(dashboard)/dashboard/combos/page.tsx | 4 + .../api/model-combo-mappings/[id]/route.ts | 69 ++++ src/app/api/model-combo-mappings/route.ts | 48 +++ .../migrations/010_model_combo_mappings.sql | 19 ++ src/lib/db/modelComboMappings.ts | 251 ++++++++++++++ src/lib/localDb.ts | 12 + src/shared/components/ModelRoutingSection.tsx | 311 ++++++++++++++++++ src/sse/handlers/chat.ts | 4 +- src/sse/services/model.ts | 28 ++ tests/unit/model-combo-mappings.test.mjs | 135 ++++++++ 10 files changed, 879 insertions(+), 2 deletions(-) create mode 100644 src/app/api/model-combo-mappings/[id]/route.ts create mode 100644 src/app/api/model-combo-mappings/route.ts create mode 100644 src/lib/db/migrations/010_model_combo_mappings.sql create mode 100644 src/lib/db/modelComboMappings.ts create mode 100644 src/shared/components/ModelRoutingSection.tsx create mode 100644 tests/unit/model-combo-mappings.test.mjs diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index d60a9688c2..11832e3cb5 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -13,6 +13,7 @@ import { EmptyState, } from "@/shared/components"; import Tooltip from "@/shared/components/Tooltip"; +import ModelRoutingSection from "@/shared/components/ModelRoutingSection"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { useNotificationStore } from "@/store/notificationStore"; import { useTranslations } from "next-intl"; @@ -598,6 +599,9 @@ export default function CombosPage() { )} + {/* Model Routing Rules (#563) */} + + {/* Combos List */} {combos.length === 0 ? ( }) { + try { + const { id } = await params; + const mapping = await getModelComboMappingById(id); + if (!mapping) { + return NextResponse.json({ error: "Mapping not found" }, { status: 404 }); + } + return NextResponse.json({ mapping }); + } catch (error: any) { + return NextResponse.json({ error: error.message || "Failed to get mapping" }, { status: 500 }); + } +} + +export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const body = await request.json(); + + const mapping = await updateModelComboMapping(id, { + pattern: body.pattern, + comboId: body.comboId, + priority: body.priority, + enabled: body.enabled, + description: body.description, + }); + + if (!mapping) { + return NextResponse.json({ error: "Mapping not found" }, { status: 404 }); + } + + return NextResponse.json({ mapping }); + } catch (error: any) { + return NextResponse.json( + { error: error.message || "Failed to update mapping" }, + { status: 500 } + ); + } +} + +export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const deleted = await deleteModelComboMapping(id); + + if (!deleted) { + return NextResponse.json({ error: "Mapping not found" }, { status: 404 }); + } + + return NextResponse.json({ success: true }); + } catch (error: any) { + return NextResponse.json( + { error: error.message || "Failed to delete mapping" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/model-combo-mappings/route.ts b/src/app/api/model-combo-mappings/route.ts new file mode 100644 index 0000000000..eefd54a6fe --- /dev/null +++ b/src/app/api/model-combo-mappings/route.ts @@ -0,0 +1,48 @@ +/** + * API: Model-Combo Mappings (#563) + * GET — List all mappings + * POST — Create a new mapping + */ + +import { NextResponse } from "next/server"; +import { getModelComboMappings, createModelComboMapping } from "@/lib/localDb"; + +export async function GET() { + try { + const mappings = await getModelComboMappings(); + return NextResponse.json({ mappings }); + } catch (error: any) { + return NextResponse.json( + { error: error.message || "Failed to list model-combo mappings" }, + { status: 500 } + ); + } +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + + if (!body.pattern || typeof body.pattern !== "string") { + return NextResponse.json({ error: "Missing or invalid 'pattern' field" }, { status: 400 }); + } + if (!body.comboId || typeof body.comboId !== "string") { + return NextResponse.json({ error: "Missing or invalid 'comboId' field" }, { status: 400 }); + } + + const mapping = await createModelComboMapping({ + pattern: body.pattern.trim(), + comboId: body.comboId, + priority: typeof body.priority === "number" ? body.priority : 0, + enabled: body.enabled !== false, + description: body.description || "", + }); + + return NextResponse.json({ mapping }, { status: 201 }); + } catch (error: any) { + return NextResponse.json( + { error: error.message || "Failed to create model-combo mapping" }, + { status: 500 } + ); + } +} diff --git a/src/lib/db/migrations/010_model_combo_mappings.sql b/src/lib/db/migrations/010_model_combo_mappings.sql new file mode 100644 index 0000000000..08b4b82228 --- /dev/null +++ b/src/lib/db/migrations/010_model_combo_mappings.sql @@ -0,0 +1,19 @@ +-- Migration 010: Model-to-Combo mappings +-- Allows users to map model name patterns (globs) to specific combos. +-- When a request comes in for a model matching a pattern, the mapped combo +-- is used automatically instead of the global default. + +CREATE TABLE IF NOT EXISTS model_combo_mappings ( + id TEXT PRIMARY KEY, + pattern TEXT NOT NULL, -- glob pattern, e.g. 'claude-*-opus*' + combo_id TEXT NOT NULL, -- references combos.id + priority INTEGER DEFAULT 0, -- higher = checked first + enabled INTEGER DEFAULT 1, -- 0 = disabled, 1 = enabled + description TEXT DEFAULT '', -- optional human-readable label + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (combo_id) REFERENCES combos(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_mcm_enabled ON model_combo_mappings(enabled); +CREATE INDEX IF NOT EXISTS idx_mcm_priority ON model_combo_mappings(priority DESC); diff --git a/src/lib/db/modelComboMappings.ts b/src/lib/db/modelComboMappings.ts new file mode 100644 index 0000000000..bb778c853a --- /dev/null +++ b/src/lib/db/modelComboMappings.ts @@ -0,0 +1,251 @@ +/** + * db/modelComboMappings.ts — Per-model combo mapping CRUD + resolution. + * + * Maps model name patterns (glob-style wildcards) to specific combos. + * When a request arrives for a model string like "claude-sonnet-4", + * the resolver checks all enabled mappings (highest priority first) + * and returns the first matching combo. + */ + +import { v4 as uuidv4 } from "uuid"; +import { getDbInstance } from "./core"; + +// ────────────────────────────────────────────────────────── +// Types +// ────────────────────────────────────────────────────────── + +export interface ModelComboMapping { + id: string; + pattern: string; + comboId: string; + comboName?: string; + priority: number; + enabled: boolean; + description: string; + createdAt: string; + updatedAt: string; +} + +interface MappingRow { + id: string; + pattern: string; + combo_id: string; + combo_name?: string; + priority: number; + enabled: number; + description: string; + created_at: string; + updated_at: string; +} + +// ────────────────────────────────────────────────────────── +// Glob → RegExp conversion +// ────────────────────────────────────────────────────────── + +/** + * Convert a simple glob pattern to a RegExp. + * Supports `*` (any characters) and `?` (single character). + * Case-insensitive matching. + */ +function globToRegex(pattern: string): RegExp { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex specials + .replace(/\*/g, ".*") // * → .* + .replace(/\?/g, "."); // ? → . + return new RegExp(`^${escaped}$`, "i"); +} + +// ────────────────────────────────────────────────────────── +// Row mapping +// ────────────────────────────────────────────────────────── + +function rowToMapping(row: MappingRow): ModelComboMapping { + return { + id: row.id, + pattern: row.pattern, + comboId: row.combo_id, + comboName: row.combo_name || undefined, + priority: row.priority, + enabled: row.enabled === 1, + description: row.description || "", + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +// ────────────────────────────────────────────────────────── +// CRUD +// ────────────────────────────────────────────────────────── + +/** + * List all model-combo mappings, joined with combo name. + * Ordered by priority descending (highest first). + */ +export async function getModelComboMappings(): Promise { + const db = getDbInstance(); + const rows = db + .prepare( + `SELECT m.id, m.pattern, m.combo_id, c.name AS combo_name, + m.priority, m.enabled, m.description, + m.created_at, m.updated_at + FROM model_combo_mappings m + LEFT JOIN combos c ON c.id = m.combo_id + ORDER BY m.priority DESC, m.created_at ASC` + ) + .all() as MappingRow[]; + return rows.map(rowToMapping); +} + +/** + * Get a single mapping by ID. + */ +export async function getModelComboMappingById(id: string): Promise { + const db = getDbInstance(); + const row = db + .prepare( + `SELECT m.id, m.pattern, m.combo_id, c.name AS combo_name, + m.priority, m.enabled, m.description, + m.created_at, m.updated_at + FROM model_combo_mappings m + LEFT JOIN combos c ON c.id = m.combo_id + WHERE m.id = ?` + ) + .get(id) as MappingRow | undefined; + return row ? rowToMapping(row) : null; +} + +/** + * Create a new model-combo mapping. + */ +export async function createModelComboMapping(data: { + pattern: string; + comboId: string; + priority?: number; + enabled?: boolean; + description?: string; +}): Promise { + const db = getDbInstance(); + const now = new Date().toISOString(); + const id = uuidv4(); + + db.prepare( + `INSERT INTO model_combo_mappings + (id, pattern, combo_id, priority, enabled, description, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + id, + data.pattern, + data.comboId, + data.priority ?? 0, + data.enabled !== false ? 1 : 0, + data.description || "", + now, + now + ); + + return { + id, + pattern: data.pattern, + comboId: data.comboId, + priority: data.priority ?? 0, + enabled: data.enabled !== false, + description: data.description || "", + createdAt: now, + updatedAt: now, + }; +} + +/** + * Update an existing model-combo mapping. + */ +export async function updateModelComboMapping( + id: string, + data: Partial<{ + pattern: string; + comboId: string; + priority: number; + enabled: boolean; + description: string; + }> +): Promise { + const existing = await getModelComboMappingById(id); + if (!existing) return null; + + const db = getDbInstance(); + const now = new Date().toISOString(); + const updated = { + pattern: data.pattern ?? existing.pattern, + combo_id: data.comboId ?? existing.comboId, + priority: data.priority ?? existing.priority, + enabled: data.enabled !== undefined ? (data.enabled ? 1 : 0) : existing.enabled ? 1 : 0, + description: data.description ?? existing.description, + }; + + db.prepare( + `UPDATE model_combo_mappings + SET pattern = ?, combo_id = ?, priority = ?, enabled = ?, + description = ?, updated_at = ? + WHERE id = ?` + ).run( + updated.pattern, + updated.combo_id, + updated.priority, + updated.enabled, + updated.description, + now, + id + ); + + return getModelComboMappingById(id); +} + +/** + * Delete a model-combo mapping. + */ +export async function deleteModelComboMapping(id: string): Promise { + const db = getDbInstance(); + const result = db.prepare("DELETE FROM model_combo_mappings WHERE id = ?").run(id); + return (result.changes ?? 0) > 0; +} + +// ────────────────────────────────────────────────────────── +// Core: Resolve combo for a model string +// ────────────────────────────────────────────────────────── + +/** + * Check if a model string matches any enabled model-combo mapping. + * Returns the full combo object if a match is found, null otherwise. + * + * Mappings are checked in priority order (highest first). + * Uses glob-style pattern matching (* = any chars, ? = single char). + */ +export async function resolveComboForModel( + modelStr: string +): Promise | null> { + const db = getDbInstance(); + + // Fetch enabled mappings, ordered by priority (highest first) + const rows = db + .prepare( + `SELECT m.pattern, m.combo_id, c.data AS combo_data + FROM model_combo_mappings m + JOIN combos c ON c.id = m.combo_id + WHERE m.enabled = 1 + ORDER BY m.priority DESC, m.created_at ASC` + ) + .all() as Array<{ pattern: string; combo_id: string; combo_data: string }>; + + for (const row of rows) { + const regex = globToRegex(row.pattern); + if (regex.test(modelStr)) { + try { + return JSON.parse(row.combo_data); + } catch { + // Corrupted combo data — skip + continue; + } + } + } + + return null; +} diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 6b8bc625c5..eb7de292b4 100644 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -171,3 +171,15 @@ export type { QuotaCheckResult, IssueKeyParams, } from "./db/registeredKeys"; + +export { + // Model-Combo Mappings (#563) + getModelComboMappings, + getModelComboMappingById, + createModelComboMapping, + updateModelComboMapping, + deleteModelComboMapping, + resolveComboForModel, +} from "./db/modelComboMappings"; + +export type { ModelComboMapping } from "./db/modelComboMappings"; diff --git a/src/shared/components/ModelRoutingSection.tsx b/src/shared/components/ModelRoutingSection.tsx new file mode 100644 index 0000000000..063f2435ac --- /dev/null +++ b/src/shared/components/ModelRoutingSection.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { useState, useEffect } from "react"; + +export interface ModelMapping { + id: string; + pattern: string; + comboId: string; + comboName?: string; + priority: number; + enabled: boolean; + description: string; +} + +interface Combo { + id: string; + name: string; +} + +export default function ModelRoutingSection({ combos = [] }: { combos?: Combo[] }) { + const [mappings, setMappings] = useState([]); + const [loading, setLoading] = useState(true); + const [adding, setAdding] = useState(false); + const [editingId, setEditingId] = useState(null); + + // Form state + const [pattern, setPattern] = useState(""); + const [comboId, setComboId] = useState(""); + const [priority, setPriority] = useState(0); + const [description, setDescription] = useState(""); + + const loadMappings = async () => { + try { + const res = await fetch("/api/model-combo-mappings"); + if (res.ok) { + const data = await res.json(); + return data.mappings || []; + } + } catch {} + return []; + }; + + useEffect(() => { + let cancelled = false; + loadMappings().then((data) => { + if (!cancelled) { + setMappings(data); + setLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, []); + + const refetchMappings = async () => { + const data = await loadMappings(); + setMappings(data); + }; + + const resetForm = () => { + setPattern(""); + setComboId(""); + setPriority(0); + setDescription(""); + setAdding(false); + setEditingId(null); + }; + + const handleSave = async () => { + if (!pattern.trim() || !comboId) return; + + try { + if (editingId) { + const res = await fetch(`/api/model-combo-mappings/${editingId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pattern: pattern.trim(), comboId, priority, description }), + }); + if (res.ok) await refetchMappings(); + } else { + const res = await fetch("/api/model-combo-mappings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pattern: pattern.trim(), comboId, priority, description }), + }); + if (res.ok) await refetchMappings(); + } + } catch {} + resetForm(); + }; + + const handleEdit = (m: ModelMapping) => { + setPattern(m.pattern); + setComboId(m.comboId); + setPriority(m.priority); + setDescription(m.description); + setEditingId(m.id); + setAdding(true); + }; + + const handleDelete = async (id: string) => { + if (!confirm("Delete this model routing rule?")) return; + try { + await fetch(`/api/model-combo-mappings/${id}`, { method: "DELETE" }); + setMappings((prev) => prev.filter((m) => m.id !== id)); + } catch {} + }; + + const handleToggle = async (m: ModelMapping) => { + try { + const res = await fetch(`/api/model-combo-mappings/${m.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: !m.enabled }), + }); + if (res.ok) { + setMappings((prev) => prev.map((x) => (x.id === m.id ? { ...x, enabled: !x.enabled } : x))); + } + } catch {} + }; + + return ( +
+
+
+ route +
+

Model Routing Rules

+

+ Automatically route models to specific combos using glob patterns +

+
+
+ {!adding && ( + + )} +
+ + {/* Inline form */} + {adding && ( +
+
+
+ + setPattern(e.target.value)} + placeholder="claude-sonnet*" + className="w-full mt-0.5 px-2.5 py-1.5 text-xs rounded-lg border border-black/10 dark:border-white/10 + bg-white dark:bg-black/20 focus:outline-none focus:ring-1 focus:ring-primary" + /> +

+ Use * for any chars, ? for single char. Case-insensitive. +

+
+
+ + +
+
+ + setPriority(Number(e.target.value))} + className="w-full mt-0.5 px-2.5 py-1.5 text-xs rounded-lg border border-black/10 dark:border-white/10 + bg-white dark:bg-black/20 focus:outline-none focus:ring-1 focus:ring-primary" + /> +

+ Higher = checked first. Use 10+ for specific patterns. +

+
+
+ + setDescription(e.target.value)} + placeholder="Route Opus models to frontier combo" + className="w-full mt-0.5 px-2.5 py-1.5 text-xs rounded-lg border border-black/10 dark:border-white/10 + bg-white dark:bg-black/20 focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+
+
+ + +
+
+ )} + + {/* Mappings list */} + {loading ? ( +
Loading...
+ ) : mappings.length === 0 ? ( +
+

+ No routing rules configured. Requests use the global combo by default. +

+

+ Add a rule like{" "} + claude-opus* + {" → "} frontier-combo to automatically route + requests. +

+
+ ) : ( +
+ {mappings.map((m) => ( +
+
+ + {m.pattern} + + + + {m.comboName || m.comboId.slice(0, 8)} + + {m.description && ( + + {m.description} + + )} + + P{m.priority} + +
+
+ + + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 6ee7a48048..4ed55ec196 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -5,7 +5,7 @@ import { extractApiKey, isValidApiKey, } from "../services/auth"; -import { getModelInfo, getCombo } from "../services/model"; +import { getModelInfo, getComboForModel } from "../services/model"; import { parseModel } from "@omniroute/open-sse/services/model.ts"; import { detectFormatFromEndpoint, @@ -234,7 +234,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) { // Check if model is a combo (has multiple models with fallback) telemetry.startPhase("resolve"); - const combo = await getCombo(resolvedModelStr); + const combo = await getComboForModel(resolvedModelStr); if (combo) { log.info( "CHAT", diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts index 5f540e539d..8e0eeae635 100644 --- a/src/sse/services/model.ts +++ b/src/sse/services/model.ts @@ -99,6 +99,34 @@ export async function getCombo(modelStr) { return null; } +/** + * Check if model matches a combo by name OR by model-combo mapping pattern. + * This augments getCombo() with glob-based model-to-combo resolution (#563). + * + * Resolution order: + * 1. Exact combo name match (existing behavior) + * 2. Model-combo mapping pattern match (new — glob patterns by priority) + * 3. null (no combo — single-model request) + */ +export async function getComboForModel(modelStr) { + // 1. Existing behavior — exact combo name match + const combo = await getCombo(modelStr); + if (combo) return combo; + + // 2. NEW — check model-combo mappings table (pattern match) + try { + const { resolveComboForModel } = await import("@/lib/localDb"); + const mapped = await resolveComboForModel(modelStr); + if (mapped && (mapped as any).models?.length > 0) { + return mapped; + } + } catch { + // If the mappings table doesn't exist yet (pre-migration), continue gracefully + } + + return null; +} + /** * Legacy: get combo models as string array * @returns {Promise} diff --git a/tests/unit/model-combo-mappings.test.mjs b/tests/unit/model-combo-mappings.test.mjs new file mode 100644 index 0000000000..13817a5b9f --- /dev/null +++ b/tests/unit/model-combo-mappings.test.mjs @@ -0,0 +1,135 @@ +/** + * Unit tests for Per-Model Combo Support (#563) + * Tests glob pattern matching and priority ordering. + */ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; + +// ────────────────────────────────────────────────────────── +// Inline glob-to-regex (same logic as modelComboMappings.ts) +// ────────────────────────────────────────────────────────── + +function globToRegex(pattern) { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/\?/g, "."); + return new RegExp(`^${escaped}$`, "i"); +} + +/** + * Simulate resolveComboForModel logic in-memory. + */ +function resolveFromMappings(modelStr, mappings) { + const enabled = mappings.filter((m) => m.enabled).sort((a, b) => b.priority - a.priority); + + for (const mapping of enabled) { + const regex = globToRegex(mapping.pattern); + if (regex.test(modelStr)) { + return mapping.comboName; + } + } + return null; +} + +// ────────────────────────────────────────────────────────── +// Test Cases +// ────────────────────────────────────────────────────────── + +describe("Model-Combo Mapping: globToRegex", () => { + test("exact match", () => { + const re = globToRegex("claude-sonnet-4"); + assert.ok(re.test("claude-sonnet-4")); + assert.ok(!re.test("claude-opus-4")); + }); + + test("wildcard * matches any characters", () => { + const re = globToRegex("claude-*-opus*"); + assert.ok(re.test("claude-sonnet-opus-4")); + assert.ok(re.test("claude-3-opus-20240229")); + assert.ok(!re.test("gpt-4o")); + }); + + test("wildcard ? matches single character", () => { + const re = globToRegex("gpt-4?"); + assert.ok(re.test("gpt-4o")); + assert.ok(re.test("gpt-4t")); + assert.ok(!re.test("gpt-4oo")); + }); + + test("case-insensitive matching", () => { + const re = globToRegex("Claude-*"); + assert.ok(re.test("claude-sonnet-4")); + assert.ok(re.test("CLAUDE-OPUS-4")); + }); + + test("escapes regex special characters", () => { + const re = globToRegex("model.v2"); + assert.ok(re.test("model.v2")); + assert.ok(!re.test("modelXv2")); // . should be literal, not regex . + }); + + test("pattern with slash (provider/model)", () => { + const re = globToRegex("cc/claude-*"); + assert.ok(re.test("cc/claude-opus-4")); + assert.ok(!re.test("gh/claude-opus-4")); + }); +}); + +describe("Model-Combo Mapping: resolveFromMappings", () => { + const mappings = [ + { pattern: "claude-*-opus*", comboName: "frontier-combo", priority: 10, enabled: true }, + { pattern: "claude-sonnet*", comboName: "code-combo", priority: 10, enabled: true }, + { pattern: "gpt-4o*", comboName: "openai-combo", priority: 5, enabled: true }, + { pattern: "gemini-*", comboName: "google-combo", priority: 3, enabled: true }, + { pattern: "disabled-*", comboName: "disabled-combo", priority: 100, enabled: false }, + { pattern: "*", comboName: "catch-all", priority: 0, enabled: true }, + ]; + + test("matches opus pattern → frontier-combo", () => { + assert.equal(resolveFromMappings("claude-3-opus-20240229", mappings), "frontier-combo"); + }); + + test("matches sonnet pattern → code-combo", () => { + assert.equal(resolveFromMappings("claude-sonnet-4", mappings), "code-combo"); + }); + + test("matches gpt-4o → openai-combo", () => { + assert.equal(resolveFromMappings("gpt-4o-mini", mappings), "openai-combo"); + }); + + test("matches gemini → google-combo", () => { + assert.equal(resolveFromMappings("gemini-2.5-flash", mappings), "google-combo"); + }); + + test("disabled mappings are skipped", () => { + assert.notEqual(resolveFromMappings("disabled-model", mappings), "disabled-combo"); + // Falls through to catch-all instead + assert.equal(resolveFromMappings("disabled-model", mappings), "catch-all"); + }); + + test("unknown model falls to catch-all (* pattern)", () => { + assert.equal(resolveFromMappings("deepseek-chat", mappings), "catch-all"); + }); + + test("no mappings returns null", () => { + assert.equal(resolveFromMappings("any-model", []), null); + }); + + test("priority ordering: higher priority wins when multiple match", () => { + const overlapping = [ + { pattern: "claude-*", comboName: "generic-claude", priority: 1, enabled: true }, + { pattern: "claude-*-opus*", comboName: "specific-opus", priority: 10, enabled: true }, + ]; + assert.equal(resolveFromMappings("claude-3-opus-4", overlapping), "specific-opus"); + }); + + test("priority ordering: lower priority loses even if listed first", () => { + const overlapping = [ + { pattern: "claude-*-opus*", comboName: "specific-opus", priority: 1, enabled: true }, + { pattern: "claude-*", comboName: "generic-claude", priority: 10, enabled: true }, + ]; + // generic-claude has higher priority (10 > 1), so it wins + assert.equal(resolveFromMappings("claude-3-opus-4", overlapping), "generic-claude"); + }); +});