mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-05-06 02:07:00 +00:00
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:
parent
4562fdda92
commit
5dc3fd2ec0
10 changed files with 879 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
69
src/app/api/model-combo-mappings/[id]/route.ts
Normal file
69
src/app/api/model-combo-mappings/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
48
src/app/api/model-combo-mappings/route.ts
Normal file
48
src/app/api/model-combo-mappings/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
19
src/lib/db/migrations/010_model_combo_mappings.sql
Normal file
19
src/lib/db/migrations/010_model_combo_mappings.sql
Normal 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);
|
||||
251
src/lib/db/modelComboMappings.ts
Normal file
251
src/lib/db/modelComboMappings.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
311
src/shared/components/ModelRoutingSection.tsx
Normal file
311
src/shared/components/ModelRoutingSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
135
tests/unit/model-combo-mappings.test.mjs
Normal file
135
tests/unit/model-combo-mappings.test.mjs
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue