mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 06:12:10 +03:00
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
312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
"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>
|
|
);
|
|
}
|