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.
This commit is contained in:
diegosouzapw
2026-04-12 18:43:19 -03:00
parent 9944d136e4
commit 25bd04e400
61 changed files with 2036 additions and 492 deletions

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
export interface ModelMapping {
id: string;
@@ -17,11 +18,14 @@ interface Combo {
name: string;
}
export default function ModelRoutingSection({ combos = [] }: { combos?: Combo[] }) {
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("");
@@ -53,6 +57,22 @@ export default function ModelRoutingSection({ combos = [] }: { combos?: Combo[]
};
}, []);
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);
@@ -100,7 +120,7 @@ export default function ModelRoutingSection({ combos = [] }: { combos?: Combo[]
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this model routing rule?")) return;
if (!confirm(t("deleteRoutingRule"))) return;
try {
await fetch(`/api/model-combo-mappings/${id}`, { method: "DELETE" });
setMappings((prev) => prev.filter((m) => m.id !== id));
@@ -126,10 +146,8 @@ export default function ModelRoutingSection({ combos = [] }: { combos?: Combo[]
<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>
<h3 className="text-sm font-semibold">{t("modelRoutingTitle")}</h3>
<p className="text-[11px] text-text-muted">{t("modelRoutingDesc")}</p>
</div>
</div>
{!adding && (
@@ -139,7 +157,7 @@ export default function ModelRoutingSection({ combos = [] }: { combos?: Combo[]
bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">add</span>
Add Rule
{t("addRule")}
</button>
)}
</div>
@@ -150,7 +168,7 @@ export default function ModelRoutingSection({ combos = [] }: { combos?: Combo[]
<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
{t("pattern")}
</label>
<input
value={pattern}
@@ -159,13 +177,11 @@ export default function ModelRoutingSection({ combos = [] }: { combos?: 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"
/>
<p className="text-[9px] text-text-muted mt-0.5">
Use * for any chars, ? for single char. Case-insensitive.
</p>
<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">
Route to Combo
{t("routeToCombo")}
</label>
<select
value={comboId}
@@ -173,7 +189,7 @@ export default function ModelRoutingSection({ combos = [] }: { combos?: 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"
>
<option value="">Select combo...</option>
<option value="">{t("selectCombo")}</option>
{combos.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
@@ -183,7 +199,7 @@ export default function ModelRoutingSection({ combos = [] }: { combos?: Combo[]
</div>
<div>
<label className="text-[10px] font-medium text-text-muted uppercase tracking-wider">
Priority
{t("priority")}
</label>
<input
type="number"
@@ -192,13 +208,11 @@ export default function ModelRoutingSection({ combos = [] }: { combos?: 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"
/>
<p className="text-[9px] text-text-muted mt-0.5">
Higher = checked first. Use 10+ for specific patterns.
</p>
<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">
Description
{t("description")}
</label>
<input
value={description}
@@ -216,14 +230,14 @@ export default function ModelRoutingSection({ combos = [] }: { combos?: Combo[]
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"}
{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"
>
Cancel
{t("cancel")}
</button>
</div>
</div>
@@ -231,18 +245,11 @@ export default function ModelRoutingSection({ combos = [] }: { combos?: Combo[]
{/* Mappings list */}
{loading ? (
<div className="mt-3 text-xs text-text-muted">Loading...</div>
<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">
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>
<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">
@@ -277,7 +284,7 @@ export default function ModelRoutingSection({ combos = [] }: { combos?: Combo[]
<button
onClick={() => handleToggle(m)}
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
title={m.enabled ? "Disable" : "Enable"}
title={m.enabled ? t("disable") : t("enable")}
>
<span
className={`material-symbols-outlined text-[14px] ${m.enabled ? "text-emerald-500" : "text-text-muted"}`}

View File

@@ -1,5 +1,5 @@
import { execSync, execFileSync } from "child_process";
import { existsSync } from "fs";
import { execFileSync, execSync } from "child_process";
import { existsSync, readFileSync } from "fs";
/**
* Get raw machine ID using OS-specific methods.
@@ -59,12 +59,7 @@ function getMachineIdRaw(): string {
try {
for (const filePath of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
try {
const content = execFileSync("cat", [filePath], {
encoding: "utf8",
timeout: 5000,
})
.trim()
.toLowerCase();
const content = readFileSync(filePath, "utf8").trim().toLowerCase();
if (content.length > 8) return content;
} catch {
// Try the next candidate file

View File

@@ -1,12 +1,14 @@
/**
* maskEmail — Privacy display utility for email addresses.
*
* Masks the username and domain name portions of an email address
* to prevent identity exposure in dashboards and logs.
* Masks both username and domain portions of an email address.
* - Username: keep the first `visibleChars`, mask the rest
* - Domain: mask everything except the final `visibleChars`
*
* @example
* maskEmail("diego.souza@gmail.com") // "die********@gmail.com"
* maskEmail("a@b.com") // "a@b.com" (too short to mask)
* maskEmail("diego.souza@outlook.com.br") // "die********@***********.br"
* maskEmail("user@gmail.com") // "use*@******.com"
* maskEmail("a@b.com") // "a@b.com" (too short to mask)
*/
export function maskEmail(email: string | null | undefined, visibleChars = 3): string {
if (!email) return "";
@@ -14,21 +16,20 @@ export function maskEmail(email: string | null | undefined, visibleChars = 3): s
const atIndex = email.lastIndexOf("@");
const username = email.slice(0, atIndex);
const rest = email.slice(atIndex + 1); // "gmail.com", "co.uk", etc.
const dotIndex = rest.indexOf(".");
const domainName = dotIndex !== -1 ? rest.slice(0, dotIndex) : rest;
const tld = dotIndex !== -1 ? rest.slice(dotIndex) : ""; // ".com", ".co.uk"
const domain = email.slice(atIndex + 1);
// If username is too short to mask meaningfully, return as-is
if (username.length <= visibleChars) return email;
const maskedUser = username.slice(0, visibleChars) + "*".repeat(username.length - visibleChars);
if (domain.length <= visibleChars) {
return `${maskedUser}@${domain}`;
}
// Preserve the full domain name to maintain clear provider account differentiation
const maskedDomain = domainName;
const maskedDomain =
"*".repeat(domain.length - visibleChars) + domain.slice(domain.length - visibleChars);
return `${maskedUser}@${maskedDomain}${tld}`;
return `${maskedUser}@${maskedDomain}`;
}
/**

View File

@@ -183,6 +183,22 @@ export const createComboSchema = z.object({
// ──── Settings Schemas ────
// FASE-01: Removed .passthrough() — only explicitly listed fields are accepted
const settingsFallbackStrategySchema = z.enum([
"priority",
"weighted",
"round-robin",
"context-relay",
"fill-first",
"p2c",
"random",
"least-used",
"cost-optimized",
"strict-random",
"auto",
"context-optimized",
"lkgp",
]);
export const updateSettingsSchema = z.object({
newPassword: z.string().min(1).max(200).optional(),
currentPassword: z.string().max(200).optional(),
@@ -200,17 +216,7 @@ export const updateSettingsSchema = z.object({
hideHealthCheckLogs: z.boolean().optional(),
hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(),
// Routing settings (#134)
fallbackStrategy: z
.enum([
"fill-first",
"round-robin",
"p2c",
"random",
"least-used",
"cost-optimized",
"strict-random",
])
.optional(),
fallbackStrategy: settingsFallbackStrategySchema.optional(),
wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(),
stickyRoundRobinLimit: z.number().int().min(0).max(1000).optional(),
// Auto intent classifier settings (multilingual routing)

View File

@@ -8,6 +8,22 @@
import { z } from "zod";
import { HIDEABLE_SIDEBAR_ITEM_IDS } from "@/shared/constants/sidebarVisibility";
const fallbackStrategyValues = [
"priority",
"weighted",
"round-robin",
"context-relay",
"fill-first",
"p2c",
"random",
"least-used",
"cost-optimized",
"strict-random",
"auto",
"context-optimized",
"lkgp",
] as const;
export const updateSettingsSchema = z.object({
newPassword: z.string().min(1).max(200).optional(),
currentPassword: z.string().max(200).optional(),
@@ -30,9 +46,7 @@ export const updateSettingsSchema = z.object({
debugMode: z.boolean().optional(),
hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(),
// Routing settings (#134)
fallbackStrategy: z
.enum(["fill-first", "round-robin", "p2c", "random", "least-used", "cost-optimized"])
.optional(),
fallbackStrategy: z.enum(fallbackStrategyValues).optional(),
wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(),
stickyRoundRobinLimit: z.number().int().min(0).max(1000).optional(),
// Auto intent classifier settings (multilingual routing)