25bd04e400
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.
319 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|