fix: routing strategy not persisted after refresh (#134)

The Zod updateSettingsSchema was missing fallbackStrategy,
wildcardAliases, and stickyRoundRobinLimit fields. Since
.passthrough() was removed in a previous cleanup, these unknown
keys were silently stripped during validation, so the PATCH
/api/settings call never actually persisted these values.
This commit is contained in:
diegosouzapw
2026-02-25 19:23:54 -03:00
parent 67fa2592b5
commit 8abdf68718
16 changed files with 118 additions and 99 deletions

View File

@@ -1,5 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { SegmentedControl } from "@/shared/components";
import PlaygroundMode from "./components/PlaygroundMode";
@@ -7,26 +9,21 @@ import ChatTesterMode from "./components/ChatTesterMode";
import TestBenchMode from "./components/TestBenchMode";
import LiveMonitorMode from "./components/LiveMonitorMode";
const MODES = [
{ value: "playground", label: "Playground", icon: "code" },
{ value: "chat-tester", label: "Chat Tester", icon: "chat" },
{ value: "test-bench", label: "Test Bench", icon: "science" },
{ value: "live-monitor", label: "Live Monitor", icon: "monitoring" },
];
const MODE_DESCRIPTIONS: Record<string, string> = {
playground:
"Paste any API request body and see how OmniRoute translates it between provider formats (OpenAI ↔ Claude ↔ Gemini ↔ Responses API)",
"chat-tester":
"Send real chat requests through OmniRoute and see the full round-trip: your input, the translated request, the provider response, and the translated output",
"test-bench":
"Define multiple test cases with different inputs and expected outputs, run them all at once, and compare results across providers and models",
"live-monitor":
"Watch incoming requests in real-time as they flow through OmniRoute — see format translations happening live and identify issues instantly",
};
export default function TranslatorPageClient() {
const t = useTranslations("translator");
const [mode, setMode] = useState("playground");
const modes = [
{ value: "playground", label: t("playground"), icon: "code" },
{ value: "chat-tester", label: t("chatTester"), icon: "chat" },
{ value: "test-bench", label: t("testBench"), icon: "science" },
{ value: "live-monitor", label: t("liveMonitor"), icon: "monitoring" },
];
const modeDescriptions: Record<string, string> = {
playground: t("modeDescriptionPlayground"),
"chat-tester": t("modeDescriptionChatTester"),
"test-bench": t("modeDescriptionTestBench"),
"live-monitor": t("modeDescriptionLiveMonitor"),
};
return (
<div className="p-8 space-y-6">
@@ -35,14 +32,13 @@ export default function TranslatorPageClient() {
<div>
<h1 className="text-2xl font-bold text-text-main flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[28px]">translate</span>
Translator Playground
{t("playgroundTitle")}
</h1>
<p className="text-sm text-text-muted mt-1">
{MODE_DESCRIPTIONS[mode] ||
"Debug, test, and visualize how OmniRoute translates API requests between providers"}
{modeDescriptions[mode] || t("modeDescriptionFallback")}
</p>
</div>
<SegmentedControl options={MODES} value={mode} onChange={setMode} size="md" />
<SegmentedControl options={modes} value={mode} onChange={setMode} size="md" />
</div>
{/* Mode Content */}

View File

@@ -13,6 +13,7 @@ import { FORMAT_META } from "../exampleTemplates";
*/
export default function LiveMonitorMode() {
const t = useTranslations("translator");
const tc = useTranslations("common");
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(true);
@@ -60,21 +61,25 @@ export default function LiveMonitorMode() {
<div>
<p className="font-medium text-text-main mb-0.5">{t("realtime")}</p>
<p>
Shows translation events as API calls flow through OmniRoute. Events come from the
in-memory buffer (resets on restart). Use{" "}
{t("liveMonitorDescription")}{" "}
<strong className="text-text-main">{t("chatTester")}</strong>,{" "}
<strong className="text-text-main">{t("testBench")}</strong>, or external API calls to
generate events.
<strong className="text-text-main">{t("testBench")}</strong>, {t("or")}{" "}
{t("externalApiCalls").toLowerCase()} {t("toGenerateEvents")}
</p>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard icon="translate" label="Total Translations" value={events.length} color="blue" />
<StatCard icon="check_circle" label="Successful" value={successCount} color="green" />
<StatCard icon="error" label="Errors" value={errorCount} color="red" />
<StatCard icon="speed" label="Avg Latency" value={`${avgLatency}ms`} color="purple" />
<StatCard
icon="translate"
label={t("totalTranslations")}
value={events.length}
color="blue"
/>
<StatCard icon="check_circle" label={t("successful")} value={successCount} color="green" />
<StatCard icon="error" label={t("errors")} value={errorCount} color="red" />
<StatCard icon="speed" label={t("avgLatency")} value={`${avgLatency}ms`} color="purple" />
</div>
{/* Controls */}
@@ -90,7 +95,7 @@ export default function LiveMonitorMode() {
onClick={() => setAutoRefresh(!autoRefresh)}
className="text-sm text-text-main hover:text-primary transition-colors"
>
{autoRefresh ? "LiveAuto-refreshing" : "Paused"}
{autoRefresh ? t("liveAutoRefreshing") : t("paused")}
</button>
</div>
<button
@@ -98,7 +103,7 @@ export default function LiveMonitorMode() {
className="flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-[16px]">refresh</span>
Refresh
{tc("refresh")}
</button>
</div>
</Card>
@@ -111,7 +116,7 @@ export default function LiveMonitorMode() {
{loading ? (
<div className="flex items-center justify-center py-12 text-text-muted">
<span className="material-symbols-outlined animate-spin mr-2">progress_activity</span>
Loading...
{tc("loading")}
</div>
) : events.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-text-muted">
@@ -120,39 +125,36 @@ export default function LiveMonitorMode() {
</span>
<p className="text-sm font-medium mb-1">{t("noTranslations")}</p>
<p className="text-xs text-center max-w-sm">
Translation events appear here as requests flow through OmniRoute. Use any of these
methods to generate events:
{t("eventsAppearHint")}
</p>
<div className="flex flex-wrap gap-2 mt-3 text-xs">
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
Chat Tester tab
{t("chatTesterTab")}
</span>
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
Test Bench tab
{t("testBenchTab")}
</span>
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
External API calls
{t("externalApiCalls")}
</span>
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
IDE/CLI integrations
{t("ideCliIntegrations")}
</span>
</div>
<p className="text-[10px] mt-3 text-text-muted/70">
Note: Events are stored in-memory and reset when the server restarts.
</p>
<p className="text-[10px] mt-3 text-text-muted/70">{t("inMemoryNote")}</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-text-muted border-b border-border">
<th className="pb-2 pr-4">{t(">time</")}</th>
<th className="pb-2 pr-4">{t(">source</")}</th>
<th className="pb-2 pr-4">{t("time")}</th>
<th className="pb-2 pr-4">{t("source")}</th>
<th className="pb-2 pr-4"></th>
<th className="pb-2 pr-4">{t(">target</")}</th>
<th className="pb-2 pr-4">{t(">model</")}</th>
<th className="pb-2 pr-4">{t(">status</")}</th>
<th className="pb-2 text-right">{t(">latency</")}</th>
<th className="pb-2 pr-4">{t("target")}</th>
<th className="pb-2 pr-4">{t("model")}</th>
<th className="pb-2 pr-4">{t("status")}</th>
<th className="pb-2 text-right">{t("latency")}</th>
</tr>
</thead>
<tbody>
@@ -195,11 +197,11 @@ export default function LiveMonitorMode() {
<td className="py-2 pr-4">
{event.status === "success" ? (
<Badge variant="success" size="sm" dot>
OK
{t("ok")}
</Badge>
) : (
<Badge variant="error" size="sm" dot>
{event.statusCode || "ERR"}
{event.statusCode || t("errorShort")}
</Badge>
)}
</td>

View File

@@ -11,6 +11,7 @@ const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
export default function PlaygroundMode() {
const t = useTranslations("translator");
const tc = useTranslations("common");
const [sourceFormat, setSourceFormat] = useState("claude");
const [targetFormat, setTargetFormat] = useState("openai");
const [inputContent, setInputContent] = useState("");
@@ -118,11 +119,7 @@ export default function PlaygroundMode() {
</span>
<div>
<p className="font-medium text-text-main mb-0.5">{t("formatConverter")}</p>
<p>
Paste or type a JSON request body. The translator will auto-detect the source format and
convert it to the target format. Use this to debug how OmniRoute translates requests
between formats (OpenAI Claude Gemini Responses API).
</p>
<p>{t("formatConverterDescription")}</p>
</div>
</div>
{/* Format Controls Bar */}
@@ -131,7 +128,7 @@ export default function PlaygroundMode() {
{/* Source Format */}
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Source Format
{t("source")}
</label>
<div className="flex items-center gap-2">
<span className={`material-symbols-outlined text-[20px] text-${srcMeta.color}-500`}>
@@ -148,7 +145,7 @@ export default function PlaygroundMode() {
/>
{detectedFormat && (
<Badge variant="primary" size="sm" icon="auto_awesome">
Auto
{t("auto")}
</Badge>
)}
</div>
@@ -158,7 +155,7 @@ export default function PlaygroundMode() {
<button
onClick={handleSwapFormats}
className="p-2 rounded-full hover:bg-primary/10 text-text-muted hover:text-primary transition-all mt-4 sm:mt-5"
title="Swap formats"
title={t("swapFormats")}
>
<span className="material-symbols-outlined text-[24px]">swap_horiz</span>
</button>
@@ -166,7 +163,7 @@ export default function PlaygroundMode() {
{/* Target Format */}
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Target Format
{t("target")}
</label>
<div className="flex items-center gap-2">
<span className={`material-symbols-outlined text-[20px] text-${tgtMeta.color}-500`}>
@@ -190,7 +187,7 @@ export default function PlaygroundMode() {
disabled={!inputContent.trim() || translating}
className="whitespace-nowrap"
>
Translate
{t("translateAction")}
</Button>
</div>
</div>
@@ -204,7 +201,7 @@ export default function PlaygroundMode() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">input</span>
<h3 className="text-sm font-semibold text-text-main">{t(">input</")}</h3>
<h3 className="text-sm font-semibold text-text-main">{t("input")}</h3>
{detectedFormat && (
<Badge variant="info" size="sm" dot>
{FORMAT_META[detectedFormat]?.label || detectedFormat}
@@ -220,7 +217,7 @@ export default function PlaygroundMode() {
<button
onClick={() => handleCopy(inputContent)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
title={tc("copy")}
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
@@ -232,7 +229,7 @@ export default function PlaygroundMode() {
setActiveTemplate(null);
}}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Clear"
title={t("clear")}
>
<span className="material-symbols-outlined text-[16px]">delete</span>
</button>
@@ -253,7 +250,7 @@ export default function PlaygroundMode() {
wordWrap: "on",
automaticLayout: true,
formatOnPaste: true,
placeholder: "Paste a request body here or select a template below...",
placeholder: t("inputPlaceholder"),
}}
/>
</div>
@@ -268,7 +265,7 @@ export default function PlaygroundMode() {
<span className="material-symbols-outlined text-[18px] text-text-muted">
output
</span>
<h3 className="text-sm font-semibold text-text-main">{t(">output</")}</h3>
<h3 className="text-sm font-semibold text-text-main">{t("output")}</h3>
{outputContent && (
<Badge variant="success" size="sm" dot>
{FORMAT_META[targetFormat]?.label || targetFormat}
@@ -279,7 +276,7 @@ export default function PlaygroundMode() {
<button
onClick={() => handleCopy(outputContent)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
title={tc("copy")}
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
@@ -313,8 +310,8 @@ export default function PlaygroundMode() {
<span className="material-symbols-outlined text-[18px] text-primary">
library_books
</span>
<h3 className="text-sm font-semibold text-text-main">Example Templates</h3>
<span className="text-xs text-text-muted"> Click to load</span>
<h3 className="text-sm font-semibold text-text-main">{t("exampleTemplates")}</h3>
<span className="text-xs text-text-muted">{t("exampleTemplatesHint")}</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-2">
{EXAMPLE_TEMPLATES.map((template) => (
@@ -342,11 +339,9 @@ export default function PlaygroundMode() {
{activeTemplate && (
<div className="flex items-center gap-2 text-xs text-text-muted">
<span className="material-symbols-outlined text-[14px]">info</span>
Template loads the request in{" "}
<strong className="text-text-main">
{FORMAT_META[sourceFormat]?.label || sourceFormat}
</strong>{" "}
format. Change Source Format to load in a different format.
{t("templateLoadHint", {
format: FORMAT_META[sourceFormat]?.label || sourceFormat,
})}
</div>
)}
</div>

View File

@@ -1,9 +1,13 @@
import TranslatorPageClient from "./TranslatorPageClient";
import { getTranslations } from "next-intl/server";
export const metadata = {
title: "Translator Playground | OmniRoute",
description: "Debug, test, and visualize API format translations between providers",
};
export async function generateMetadata() {
const t = await getTranslations("translator");
return {
title: t("metaTitle"),
description: t("metaDescription"),
};
}
export default function TranslatorPage() {
return <TranslatorPageClient />;

View File

@@ -149,7 +149,7 @@ export default function BudgetTab() {
</div>
<div className="mb-4">
<label className="text-sm text-text-muted mb-1 block">{t(">apiKey</")}</label>
<label className="text-sm text-text-muted mb-1 block">{t("apiKey")}</label>
<select
value={selectedKey || ""}
onChange={(e) => setSelectedKey(e.target.value)}

View File

@@ -66,7 +66,7 @@ export default function BudgetTelemetryCards() {
{cache ? (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-text-muted">{t(">entries</")}</span>
<span className="text-text-muted">{t("entries")}</span>
<span className="font-mono">
{cache.size}/{cache.maxSize}
</span>

View File

@@ -292,7 +292,7 @@ export default function EvalsTab() {
<div className="w-10 h-10 rounded-full bg-violet-500/20 flex items-center justify-center mb-3">
<span className="text-lg font-bold text-violet-400">1</span>
</div>
<h4 className="text-sm font-semibold text-text-main mb-1">{t(">define</")}</h4>
<h4 className="text-sm font-semibold text-text-main mb-1">{t("define")}</h4>
<p className="text-xs text-text-muted">
Create test cases with input prompts and expected output criteria using strategies
like contains, regex, or exact match.
@@ -302,7 +302,7 @@ export default function EvalsTab() {
<div className="w-10 h-10 rounded-full bg-sky-500/20 flex items-center justify-center mb-3">
<span className="text-lg font-bold text-sky-400">2</span>
</div>
<h4 className="text-sm font-semibold text-text-main mb-1">{t(">run</")}</h4>
<h4 className="text-sm font-semibold text-text-main mb-1">{t("run")}</h4>
<p className="text-xs text-text-muted">
Execute test cases against your LLM endpoints through OmniRoute. Each case is sent
as a real API request.
@@ -312,7 +312,7 @@ export default function EvalsTab() {
<div className="w-10 h-10 rounded-full bg-emerald-500/20 flex items-center justify-center mb-3">
<span className="text-lg font-bold text-emerald-400">3</span>
</div>
<h4 className="text-sm font-semibold text-text-main mb-1">{t(">evaluate</")}</h4>
<h4 className="text-sm font-semibold text-text-main mb-1">{t("evaluate")}</h4>
<p className="text-xs text-text-muted">
Responses are compared against expected criteria. See pass/fail for each case with
latency metrics and detailed feedback.

View File

@@ -376,10 +376,10 @@ export default function ProviderLimits() {
className="items-center px-4 py-2.5 border-b border-white/[0.06] text-[11px] font-semibold uppercase tracking-wider text-text-muted"
style={{ display: "grid", gridTemplateColumns: "280px 1fr 100px 48px" }}
>
<div>{t(">account</")}</div>
<div>{t("account")}</div>
<div>{t("modelQuotas")}</div>
<div className="text-center">{t(">lastUsed</")}</div>
<div className="text-center">{t(">actions</")}</div>
<div className="text-center">{t("lastUsed")}</div>
<div className="text-center">{t("actions")}</div>
</div>
{visibleConnections.map((conn, idx) => {

View File

@@ -68,16 +68,16 @@ export default function SessionsTab() {
<thead>
<tr className="border-b border-border/30">
<th className="text-left py-2 px-3 text-xs font-semibold text-text-muted uppercase tracking-wider">
{t(">session</")}
{t("session")}
</th>
<th className="text-left py-2 px-3 text-xs font-semibold text-text-muted uppercase tracking-wider">
{t(">age</")}
{t("age")}
</th>
<th className="text-right py-2 px-3 text-xs font-semibold text-text-muted uppercase tracking-wider">
{t(">requests</")}
{t("requests")}
</th>
<th className="text-left py-2 px-3 text-xs font-semibold text-text-muted uppercase tracking-wider">
{t(">connection</")}
{t("connection")}
</th>
</tr>
</thead>

View File

@@ -19,7 +19,7 @@ export default function ForgotPasswordPage() {
<div className="min-h-screen flex items-center justify-center bg-bg p-4">
<div className="w-full max-w-lg">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-primary mb-2">{t(">resetPassword</")}</h1>
<h1 className="text-3xl font-bold text-primary mb-2">{t("resetPassword")}</h1>
<p className="text-text-muted">{t("resetDescription")}</p>
</div>

View File

@@ -104,7 +104,7 @@ export default function LoginPage() {
rocket_launch
</span>
</div>
<h1 className="text-3xl font-bold text-text-main tracking-tight">{t(">welcome</")}</h1>
<h1 className="text-3xl font-bold text-text-main tracking-tight">{t("welcome")}</h1>
<p className="text-text-muted mt-2">
Let&apos;s get your OmniRoute instance configured
</p>
@@ -189,13 +189,13 @@ export default function LoginPage() {
</div>
<span className="text-xl font-semibold text-text-main tracking-tight">OmniRoute</span>
</div>
<h1 className="text-2xl font-bold text-text-main tracking-tight">{t(">signIn</")}</h1>
<h1 className="text-2xl font-bold text-text-main tracking-tight">{t("signIn")}</h1>
<p className="text-text-muted mt-1.5">{t("enterPassword")}</p>
</div>
<form onSubmit={handleLogin} className="space-y-5">
<div className="space-y-2">
<label className="text-sm font-medium text-text-main">{t(">password</")}</label>
<label className="text-sm font-medium text-text-main">{t("password")}</label>
<Input
type="password"
placeholder="Enter your password"

View File

@@ -551,6 +551,7 @@
"displayNameHint": "Optional. A friendly name to identify this configuration.",
"active": "Active",
"activeDescription": "Enable this provider for use in your applications",
"cancel": "Cancel",
"createProvider": "Create Provider",
"failedCreate": "Failed to create provider",
"errorOccurred": "An error occurred. Please try again.",
@@ -934,6 +935,9 @@
"noTranslations": "No translations yet",
"source": "Source",
"target": "Target",
"time": "Time",
"model": "Model",
"status": "Status",
"latency": "Latency",
"formatConverter": "Format Converter",
"input": "Input",
@@ -975,7 +979,10 @@
"connection": "Connection",
"providerLimits": "Provider Limits",
"noProviders": "No Providers Connected",
"account": "Account",
"modelQuotas": "Model Quotas",
"lastUsed": "Last Used",
"actions": "Actions",
"noQuotaData": "No quota data",
"noQuotaDataAvailable": "No quota data available"
},
@@ -1035,6 +1042,7 @@
"tokenHealth": "Token Health",
"totalOAuth": "Total OAuth",
"healthy": "Healthy",
"warning": "Warning",
"errored": "Errored",
"lastCheck": "Last check",
"noData": "No data",

View File

@@ -551,6 +551,7 @@
"displayNameHint": "Opcional. Um nome amigável para identificar esta configuração.",
"active": "Ativo",
"activeDescription": "Habilitar este provedor para uso em suas aplicações",
"cancel": "Cancelar",
"createProvider": "Criar Provedor",
"failedCreate": "Falha ao criar provedor",
"errorOccurred": "Ocorreu um erro. Tente novamente.",
@@ -934,6 +935,9 @@
"noTranslations": "Nenhuma tradução ainda",
"source": "Origem",
"target": "Destino",
"time": "Hora",
"model": "Modelo",
"status": "Status",
"latency": "Latência",
"formatConverter": "Conversor de Formato",
"input": "Entrada",
@@ -975,7 +979,10 @@
"connection": "Conexão",
"providerLimits": "Limites do Provedor",
"noProviders": "Nenhum Provedor Conectado",
"account": "Conta",
"modelQuotas": "Cotas de Modelo",
"lastUsed": "Último uso",
"actions": "Ações",
"noQuotaData": "Sem dados de cota",
"noQuotaDataAvailable": "Nenhum dado de cota disponível"
},
@@ -1035,6 +1042,7 @@
"tokenHealth": "Saúde dos Tokens",
"totalOAuth": "Total OAuth",
"healthy": "Saudável",
"warning": "Aviso",
"errored": "Com Erro",
"lastCheck": "Última verificação",
"noData": "Sem dados",

View File

@@ -109,7 +109,7 @@ export default function Footer() {
{/* Product */}
<div>
<h4 className="font-semibold text-text-main mb-4">{t(">product</")}</h4>
<h4 className="font-semibold text-text-main mb-4">{t("product")}</h4>
<ul className="flex flex-col gap-3 text-sm text-text-muted font-light">
{footerLinks.product.map((link) => (
<li key={link.label}>{renderFooterLink(link)}</li>
@@ -119,7 +119,7 @@ export default function Footer() {
{/* Resources */}
<div>
<h4 className="font-semibold text-text-main mb-4">{t(">resources</")}</h4>
<h4 className="font-semibold text-text-main mb-4">{t("resources")}</h4>
<ul className="flex flex-col gap-3 text-sm text-text-muted font-light">
{footerLinks.resources.map((link) => (
<li key={link.label}>{renderFooterLink(link)}</li>
@@ -129,7 +129,7 @@ export default function Footer() {
{/* Company */}
<div>
<h4 className="font-semibold text-text-main mb-4">{t(">company</")}</h4>
<h4 className="font-semibold text-text-main mb-4">{t("company")}</h4>
<ul className="flex flex-col gap-3 text-sm text-text-muted font-light">
{footerLinks.company.map((link) => (
<li key={link.label}>{renderFooterLink(link)}</li>

View File

@@ -81,18 +81,18 @@ export default function TokenHealthBadge() {
<span className="text-text-main">{health.total}</span>
</div>
<div className="flex justify-between">
<span className="text-emerald-400">{t(">healthy</")}</span>
<span className="text-emerald-400">{t("healthy")}</span>
<span className="text-text-main">{health.healthy}</span>
</div>
{health.errored > 0 && (
<div className="flex justify-between">
<span className="text-red-400">{t(">errored</")}</span>
<span className="text-red-400">{t("errored")}</span>
<span className="text-text-main">{health.errored}</span>
</div>
)}
{health.warning > 0 && (
<div className="flex justify-between">
<span className="text-amber-400">{t(">warning</")}</span>
<span className="text-amber-400">{t("warning")}</span>
<span className="text-text-main">{health.warning}</span>
</div>
)}

View File

@@ -73,6 +73,12 @@ export const updateSettingsSchema = z.object({
requireAuthForModels: z.boolean().optional(),
blockedProviders: z.array(z.string().max(100)).optional(),
hideHealthCheckLogs: z.boolean().optional(),
// Routing settings (#134)
fallbackStrategy: z
.enum(["fill-first", "round-robin", "p2c", "random", "least-used", "cost-optimized"])
.optional(),
wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(),
stickyRoundRobinLimit: z.number().int().min(0).max(1000).optional(),
});
// ──── Auth Schemas ────