feat: per-model combo routing support (#563)

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
This commit is contained in:
diegosouzapw 2026-03-23 20:36:00 -03:00
parent 4562fdda92
commit 5dc3fd2ec0
10 changed files with 879 additions and 2 deletions

View file

@ -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() {
</Card>
)}
{/* Model Routing Rules (#563) */}
<ModelRoutingSection combos={combos} />
{/* Combos List */}
{combos.length === 0 ? (
<EmptyState

View file

@ -0,0 +1,69 @@
/**
* API: Model-Combo Mapping by ID (#563)
* PUT Update a mapping
* DELETE Delete a mapping
*/
import { NextResponse } from "next/server";
import {
updateModelComboMapping,
deleteModelComboMapping,
getModelComboMappingById,
} from "@/lib/localDb";
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
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 }
);
}
}

View file

@ -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 }
);
}
}

View file

@ -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);

View file

@ -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<ModelComboMapping[]> {
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<ModelComboMapping | null> {
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<ModelComboMapping> {
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<ModelComboMapping | null> {
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<boolean> {
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<Record<string, unknown> | 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;
}

View file

@ -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";

View file

@ -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<ModelMapping[]>([]);
const [loading, setLoading] = useState(true);
const [adding, setAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(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 (
<div className="rounded-xl border border-black/10 dark:border-white/10 bg-white/50 dark:bg-white/[0.02] p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[18px]">route</span>
<div>
<h3 className="text-sm font-semibold">Model Routing Rules</h3>
<p className="text-[11px] text-text-muted">
Automatically route models to specific combos using glob patterns
</p>
</div>
</div>
{!adding && (
<button
onClick={() => setAdding(true)}
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-lg
bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">add</span>
Add Rule
</button>
)}
</div>
{/* Inline form */}
{adding && (
<div className="mt-3 p-3 rounded-lg border border-primary/20 bg-primary/[0.03] dark:bg-primary/[0.06]">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div>
<label className="text-[10px] font-medium text-text-muted uppercase tracking-wider">
Pattern
</label>
<input
value={pattern}
onChange={(e) => 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"
/>
<p className="text-[9px] text-text-muted mt-0.5">
Use * for any chars, ? for single char. Case-insensitive.
</p>
</div>
<div>
<label className="text-[10px] font-medium text-text-muted uppercase tracking-wider">
Route to Combo
</label>
<select
value={comboId}
onChange={(e) => setComboId(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"
>
<option value="">Select combo...</option>
{combos.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className="text-[10px] font-medium text-text-muted uppercase tracking-wider">
Priority
</label>
<input
type="number"
value={priority}
onChange={(e) => 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"
/>
<p className="text-[9px] text-text-muted mt-0.5">
Higher = checked first. Use 10+ for specific patterns.
</p>
</div>
<div>
<label className="text-[10px] font-medium text-text-muted uppercase tracking-wider">
Description
</label>
<input
value={description}
onChange={(e) => 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"
/>
</div>
</div>
<div className="flex items-center gap-2 mt-2.5">
<button
onClick={handleSave}
disabled={!pattern.trim() || !comboId}
className="px-3 py-1 text-xs font-medium rounded-lg bg-primary text-white
hover:bg-primary/90 disabled:opacity-40 transition-colors"
>
{editingId ? "Update" : "Save"}
</button>
<button
onClick={resetForm}
className="px-3 py-1 text-xs font-medium rounded-lg
bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
>
Cancel
</button>
</div>
</div>
)}
{/* Mappings list */}
{loading ? (
<div className="mt-3 text-xs text-text-muted">Loading...</div>
) : mappings.length === 0 ? (
<div className="mt-3 text-center py-4">
<p className="text-xs text-text-muted">
No routing rules configured. Requests use the global combo by default.
</p>
<p className="text-[10px] text-text-muted mt-1">
Add a rule like{" "}
<code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/5">claude-opus*</code>
{" → "} <span className="font-medium">frontier-combo</span> to automatically route
requests.
</p>
</div>
) : (
<div className="mt-3 flex flex-col gap-1.5">
{mappings.map((m) => (
<div
key={m.id}
className={`flex items-center justify-between px-3 py-2 rounded-lg border transition-colors
${
m.enabled
? "border-black/10 dark:border-white/10 bg-white/70 dark:bg-white/[0.02]"
: "border-black/5 dark:border-white/5 bg-black/[0.02] dark:bg-white/[0.01] opacity-50"
}`}
>
<div className="flex items-center gap-3 min-w-0">
<code className="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-700 dark:text-amber-300 font-mono shrink-0">
{m.pattern}
</code>
<span className="text-text-muted text-[10px]"></span>
<span className="text-xs font-medium text-primary truncate">
{m.comboName || m.comboId.slice(0, 8)}
</span>
{m.description && (
<span className="text-[10px] text-text-muted truncate hidden sm:inline">
{m.description}
</span>
)}
<span className="text-[9px] px-1 py-0.5 rounded bg-black/5 dark:bg-white/5 text-text-muted shrink-0">
P{m.priority}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => handleToggle(m)}
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
title={m.enabled ? "Disable" : "Enable"}
>
<span
className={`material-symbols-outlined text-[14px] ${m.enabled ? "text-emerald-500" : "text-text-muted"}`}
>
{m.enabled ? "toggle_on" : "toggle_off"}
</span>
</button>
<button
onClick={() => handleEdit(m)}
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
title="Edit"
>
<span className="material-symbols-outlined text-[14px] text-text-muted">
edit
</span>
</button>
<button
onClick={() => handleDelete(m.id)}
className="p-1 rounded hover:bg-red-500/10 transition-colors"
title="Delete"
>
<span className="material-symbols-outlined text-[14px] text-red-500">delete</span>
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -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",

View file

@ -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<string[]|null>}

View file

@ -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");
});
});