Files
OmniRoute/src/shared/components/ModelRoutingSection.tsx
T
diegosouzapw 25bd04e400 feat(settings): unify routing rules and model aliases controls
Move model routing management into Settings and add a unified
model alias editor for exact and wildcard remaps.

Sync combo defaults with global routing strategy settings, add
localized combo onboarding copy, and expand routing i18n across
supported locales.

Also fix supporting routing behavior by accepting all strategy
values in settings schemas, preserving connection ids in combo
tests, honoring non-stream JSON requests for CC-compatible
providers, and handling hashed external package subpaths.
2026-04-12 18:43:19 -03:00

319 lines
12 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
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: externalCombos }: { combos?: Combo[] } = {}) {
const t = useTranslations("settings");
const [mappings, setMappings] = useState<ModelMapping[]>([]);
const [internalCombos, setInternalCombos] = useState<Combo[]>([]);
const [loading, setLoading] = useState(true);
const [adding, setAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const combos = externalCombos || internalCombos;
// 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;
};
}, []);
useEffect(() => {
if (externalCombos !== undefined) return;
let cancelled = false;
fetch("/api/combos")
.then((res) => (res.ok ? res.json() : { combos: [] }))
.then((data) => {
if (!cancelled) {
setInternalCombos(Array.isArray(data?.combos) ? data.combos : []);
}
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [externalCombos]);
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(t("deleteRoutingRule"))) 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">{t("modelRoutingTitle")}</h3>
<p className="text-[11px] text-text-muted">{t("modelRoutingDesc")}</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>
{t("addRule")}
</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">
{t("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">{t("patternHint")}</p>
</div>
<div>
<label className="text-[10px] font-medium text-text-muted uppercase tracking-wider">
{t("routeToCombo")}
</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="">{t("selectCombo")}</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">
{t("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">{t("priorityHint")}</p>
</div>
<div>
<label className="text-[10px] font-medium text-text-muted uppercase tracking-wider">
{t("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 ? t("update") : t("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"
>
{t("cancel")}
</button>
</div>
</div>
)}
{/* Mappings list */}
{loading ? (
<div className="mt-3 text-xs text-text-muted">{t("loading")}</div>
) : mappings.length === 0 ? (
<div className="mt-3 text-center py-4">
<p className="text-xs text-text-muted">{t("noRoutingRules")}</p>
<p className="text-[10px] text-text-muted mt-1">{t("routingRuleHint")}</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 ? t("disable") : t("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>
);
}