feat(dashboard): add a dedicated API-key routing editor (#13555)

Merged. Moving rule editing out of the permissions modal into `/dashboard/api-manager/routing` fixes the real problem (a cramped modal for something with conditions, effects and three target kinds), and the contract tests pin what matters: persisted fields round-trip, combo names match without rewriting off-catalog IDs, failed saves stay editable, unsaved drafts survive key switches, and writes are disabled after a failed load. Rule evaluation and authorization are untouched.

Validated as a combined board first (this PR merged with the 4 siblings of the JxnLexn wave on the release tip): eslint with the frozen suppressions, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 77 passing / 0 failing focused node:test cases across the test files the wave touches. The wave's i18n fill (new keys carried to all 66 locales), free-tier doc counts and file-size rebaseline land in one follow-up PR right after the wave, as with #13904.

Thank you.
This commit is contained in:
Jan Leon
2026-09-16 21:38:04 +02:00
committed by GitHub
parent 502e614850
commit a16705344e
11 changed files with 1178 additions and 308 deletions

View File

@@ -0,0 +1 @@
- **feat(dashboard):** Add a dedicated, full-width API-key routing editor with explicit model/combo choices, searchable selectors and protection for unsaved rule drafts. ([#13555](https://github.com/diegosouzapw/OmniRoute/pull/13555)) — thanks @JxnLexn

View File

@@ -33,7 +33,7 @@ import { BypassProviderQuotaToggle } from "./components/BypassProviderQuotaToggl
import { ApiKeyCompressionToggle } from "./components/ApiKeyCompressionToggle";
import { AllowedCombosSection } from "./components/AllowedCombosSection";
import ProviderModelPermissionList from "./components/ProviderModelPermissionList";
import ReasoningRoutingRules from "@/shared/components/ReasoningRoutingRules";
import RoutingEntryLink from "@/shared/components/routing/RoutingEntryLink";
import { ALL_COMBOS_ACCESS_RULE } from "@/shared/constants/comboAccess";
// Constants for validation
@@ -1013,6 +1013,8 @@ export default function ApiManagerPageClient() {
</Button>
</div>
<RoutingEntryLink />
{/* Filter Bar — shown when there are keys */}
{keys.length > 0 && (
<ApiKeyFilterBar
@@ -2168,7 +2170,7 @@ const PermissionsModal = memo(function PermissionsModal({
</div>
)}
{apiKey?.id && <ReasoningRoutingRules apiKeyId={apiKey.id} />}
{apiKey?.id && <RoutingEntryLink apiKeyId={apiKey.id} />}
{/* Access Mode Toggle */}
<div className="flex gap-2 p-1 bg-surface rounded-lg">

View File

@@ -0,0 +1,26 @@
"use client";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import ReasoningRoutingRules from "@/shared/components/ReasoningRoutingRules";
export default function RoutingPageClient() {
const t = useTranslations("reasoningRouting.editor");
const searchParams = useSearchParams();
return (
<div className="min-w-0 w-full space-y-6 pb-10">
<header className="space-y-3">
<Link href="/dashboard/api-manager" className="text-sm text-primary hover:underline">
{t("backToKeys")}
</Link>
<h1 className="text-2xl font-semibold tracking-tight text-text-main">{t("pageTitle")}</h1>
<p className="max-w-3xl text-sm leading-relaxed text-text-muted">{t("pageDescription")}</p>
</header>
<ReasoningRoutingRules
key={searchParams.get("apiKeyId") || "all"}
initialApiKeyId={searchParams.get("apiKeyId") || ""}
/>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { Suspense } from "react";
import RoutingPageClient from "./RoutingPageClient";
export default function ApiKeyRoutingPage() {
return (
<Suspense>
<RoutingPageClient />
</Suspense>
);
}

View File

@@ -9,7 +9,7 @@ import ComboDefaultsTab from "../components/ComboDefaultsTab";
import FallbackChainsEditor from "../components/FallbackChainsEditor";
import ModelAliasesUnified from "../components/ModelAliasesUnified";
import BackgroundDegradationTab from "../components/BackgroundDegradationTab";
import ReasoningRoutingRules from "@/shared/components/ReasoningRoutingRules";
import RoutingEntryLink from "@/shared/components/routing/RoutingEntryLink";
export default function SettingsRoutingPage() {
const t = useTranslations("settings");
@@ -19,7 +19,7 @@ export default function SettingsRoutingPage() {
<RoutingStrategyCard />
<QuotaPreflightCard />
<ComboDefaultsTab />
<ReasoningRoutingRules />
<RoutingEntryLink />
<ModelAliasesUnified />
<FallbackChainsEditor />
<ModelRoutingSection />

View File

@@ -13290,6 +13290,65 @@
"viewFullHistory": "Vollständigen Verlauf auf GitHub anzeigen"
},
"reasoningRouting": {
"editor": {
"pageTitle": "API-Key-Routing",
"pageDescription": "Wähle einen Key, lege die Bedingungen fest und entscheide, wie seine Anfragen verarbeitet werden. Routing erweitert keine Modellberechtigungen.",
"backToKeys": "Zurück zu den API-Keys",
"entryHint": "Modell- und Reasoning-Regeln im eigenen Editor verwalten. Zugriffsberechtigungen bleiben hier.",
"configure": "Routing konfigurieren",
"workspace": "API-Key auswählen",
"scopeHint": "Verwalte Regeln für einzelne Keys getrennt oder zeige alle Geltungsbereiche an.",
"allScopes": "Alle Keys & übergreifende Regeln",
"keyScopeNotice": "Hier siehst du nur die Regeln dieses Keys. Übergreifende Regeln können ebenfalls greifen; die Prüfung gespeicherter Regeln berücksichtigt sie.",
"newRule": "Neue Regel",
"editRule": "Regel bearbeiten",
"ruleList": "Routing-Regeln",
"ruleCount": "{count, plural, =0 {Keine Regeln in diesem Bereich} one {# Regel in diesem Bereich} other {# Regeln in diesem Bereich}}",
"loading": "Routing-Konfiguration wird geladen…",
"missingKey": "Dieser API-Key existiert nicht mehr. Wähle einen anderen Key.",
"retry": "Erneut versuchen",
"when": "Wann gilt diese Regel?",
"then": "Was soll geändert werden?",
"editorHint": "Bedingung und Aktion getrennt festlegen. Bestehende Regeln behalten ihr bisheriges Matching-Verhalten.",
"sourceType": "Anfragen abgleichen für",
"sourceAll": "Alle angefragten Modelle und Combos",
"sourceCombo": "Eine angefragte Combo",
"sourceModel": "Ein angefragtes Modell",
"sourcePattern": "Eine genaue ID oder ein Muster (erweitert)",
"exactOrPattern": "Genaue Anfrage-ID oder Muster",
"patternHint": "Exakter Modell-/Alias-/Combo-Name, * für beliebige Zeichen oder ? für ein Zeichen. Bestehende Werte bleiben erhalten.",
"matchStageHint": "Der Abgleich erfolgt vor der Auflösung einer Combo. Wähle für eine Combo-Anfrage die Combo selbst — nicht ein darin enthaltenes Modell.",
"searchChoice": "Suchen: {label}",
"searchPlaceholder": "Nach Name oder vollständiger ID filtern…",
"noChoices": "Keine Treffer. Passe die Suche an oder verwende eine genaue ID / ein Muster.",
"catalogError": "Der Modellkatalog konnte nicht geladen werden. Verwende stattdessen eine genaue ID / ein Muster.",
"customTarget": "Zielmodell-ID / Alias",
"customTargetHint": "Ausgewählte IDs erscheinen hier. Du kannst auch eine eigene Modell-ID oder einen bestehenden Alias eingeben.",
"effort": {
"inherit": "Anfrage-Effort übernehmen",
"default": "Als Standard verwenden",
"force": "Effort erzwingen"
},
"effortHint": {
"inherit": "Den Reasoning-Effort der Anfrage unverändert lassen.",
"default": "Diesen Effort nur verwenden, wenn die Anfrage kein Reasoning-Signal enthält.",
"force": "Diesen Effort auch bei einer anderen Client-Vorgabe anfordern. Prüfe den endgültigen Upstream-Request, um das Provider-Verhalten zu bestätigen."
},
"advanced": "Erweitert: Tags, Priorität & Thinking-Budget",
"priorityHint": "Höhere Werte gewinnen innerhalb desselben Geltungsbereichs. API-Key-Regeln haben Vorrang vor Combo-, Modell- und globalen Regeln.",
"draftSummary": "Zusammenfassung der Regel",
"draftNotice": "Dies fasst den Entwurf zusammen und ist keine Laufzeitsimulation. Es wird keine Anfrage versendet.",
"unsaved": "Ungespeicherte Änderungen",
"discard": "Ungespeicherte Änderungen an dieser Regel verwerfen?",
"checkTitle": "Gespeicherte Regeln prüfen",
"savedOnly": "Prüft gespeicherte Regeln anhand einer Beispielanfrage. Dabei wird kein KI-Provider aufgerufen und der endgültige Upstream-Request nicht geprüft.",
"saveBeforeCheck": "Speichere oder verwirf den Entwurf, bevor du gespeicherte Regeln prüfst.",
"matched": "Passende Regel: {name}",
"notMatched": "Keine gespeicherte Regel passt",
"notMatchedHint": "Prüfe den gewählten API-Key, den genauen Modell- oder Combo-Namen der Anfrage, den Quell-Effort und die Tags.",
"technicalDetails": "Technische Details",
"simulationError": "Die gespeicherten Regeln konnten nicht geprüft werden. Bitte versuche es erneut."
},
"title": "Reasoning-Routing-Policies",
"apiKeyTitle": "Reasoning-Routing für diesen API-Key",
"subtitle": "Leite Modelle und Reasoning Effort um, ohne Client-Unterstützung vorauszusetzen. Ohne passende Regel bleibt die Anfrage unverändert.",

View File

@@ -13290,6 +13290,65 @@
"viewFullHistory": "View full history on GitHub"
},
"reasoningRouting": {
"editor": {
"pageTitle": "API-key routing",
"pageDescription": "Choose a key, define when a rule applies, and decide how to route its requests. Routing does not grant additional model permissions.",
"backToKeys": "Back to API keys",
"entryHint": "Manage model and reasoning rules in a full-width editor. Access permissions stay here.",
"configure": "Configure routing",
"workspace": "Choose an API key",
"scopeHint": "Keep rules for individual keys separate, or manage rules across all scopes.",
"allScopes": "All keys & shared rules",
"keyScopeNotice": "Only this key's rules are shown. Shared rules may also apply at runtime; the saved-rule check includes them.",
"newRule": "New rule",
"editRule": "Edit rule",
"ruleList": "Routing rules",
"ruleCount": "{count, plural, =0 {No rules in this scope} one {# rule in this scope} other {# rules in this scope}}",
"loading": "Loading routing configuration…",
"missingKey": "This API key no longer exists. Choose another key.",
"retry": "Retry",
"when": "When does this rule apply?",
"then": "What should change?",
"editorHint": "Define the condition and action separately. Existing rules keep their current matching behavior.",
"sourceType": "Match requests for",
"sourceAll": "All requested models and combos",
"sourceCombo": "A requested combo",
"sourceModel": "A requested model",
"sourcePattern": "An exact ID or wildcard (advanced)",
"exactOrPattern": "Exact request ID or pattern",
"patternHint": "Use an exact model/alias/combo name, * for any characters, or ? for one character. Existing values are preserved.",
"matchStageHint": "Matching happens before a combo is expanded. To affect a combo request, select the combo itself — not a model inside it.",
"searchChoice": "Search: {label}",
"searchPlaceholder": "Filter by name or full ID…",
"noChoices": "No matches. Adjust your search or use an exact ID / wildcard.",
"catalogError": "The model catalog could not be loaded. Use an exact ID / wildcard instead.",
"customTarget": "Target model ID / alias",
"customTargetHint": "Selected IDs appear here. You can also enter a custom model ID or existing alias.",
"effort": {
"inherit": "Keep request effort",
"default": "Use as default",
"force": "Force effort"
},
"effortHint": {
"inherit": "Leave the request's reasoning effort unchanged.",
"default": "Use this effort only when the request has no reasoning signal.",
"force": "Request this effort even when the client specifies another value. Check the final upstream request to verify provider behavior."
},
"advanced": "Advanced: tags, priority & thinking budget",
"priorityHint": "Higher values win within the same scope. API-key rules take precedence over combo, model and global rules.",
"draftSummary": "Rule summary",
"draftNotice": "This summarizes the editor, not a runtime simulation. No request is sent.",
"unsaved": "Unsaved changes",
"discard": "Discard the unsaved changes to this rule?",
"checkTitle": "Check saved rules",
"savedOnly": "Check the currently saved rules for a sample request. This does not call an AI provider or verify the final upstream payload.",
"saveBeforeCheck": "Save or discard the draft before checking saved rules.",
"matched": "Matched rule: {name}",
"notMatched": "No saved rule matches",
"notMatchedHint": "Check the selected API key, exact request model or combo name, source effort and tags.",
"technicalDetails": "Technical details",
"simulationError": "Could not check the saved rules. Please try again."
},
"title": "Reasoning routing policies",
"apiKeyTitle": "Reasoning routing for this API key",
"subtitle": "Reroute models and reasoning effort without requiring client support. Requests remain unchanged when no rule matches.",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
"use client";
import { useId, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import Input from "../Input";
import Select from "../Select";
export type RoutingOption = { value: string; label: string };
/** Search affects suggestions only; existing and off-catalog IDs are never rewritten. */
export default function RoutingChoice({
label,
value,
onChange,
options,
required = false,
}: {
label: string;
value: string;
onChange: (value: string) => void;
options: RoutingOption[];
required?: boolean;
}) {
const t = useTranslations("reasoningRouting.editor");
const id = useId();
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const term = query.trim().toLowerCase();
const matches = options.filter((option) =>
(option.label + " " + option.value).toLowerCase().includes(term)
);
if (value && !matches.some((option) => option.value === value)) {
matches.unshift({ value, label: options.find((o) => o.value === value)?.label || value });
}
return matches;
}, [options, query, value]);
return (
<div className="min-w-0 space-y-2">
<Input
id={id + "-search"}
label={t("searchChoice", { label })}
placeholder={t("searchPlaceholder")}
value={query}
onChange={(event) => setQuery(event.target.value)}
icon="search"
/>
<Select
label={label}
value={value}
onChange={(event) => onChange(event.target.value)}
options={filtered}
required={required}
/>
{value && <p className="break-all font-mono text-xs text-text-muted">{value}</p>}
{!filtered.length && <p className="text-sm text-text-muted">{t("noChoices")}</p>}
</div>
);
}

View File

@@ -0,0 +1,29 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
export default function RoutingEntryLink({ apiKeyId }: { apiKeyId?: string }) {
const t = useTranslations("reasoningRouting.editor");
return (
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border border-border bg-surface p-4">
<div className="min-w-0 flex-1">
<p className="font-medium text-text-main">{t("pageTitle")}</p>
<p className="mt-1 text-sm text-text-muted">{t("entryHint")}</p>
</div>
<Link
href={
apiKeyId
? "/dashboard/api-manager/routing?apiKeyId=" + encodeURIComponent(apiKeyId)
: "/dashboard/api-manager/routing"
}
className="inline-flex shrink-0 items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium text-primary hover:bg-primary/10 focus-visible:outline-2 focus-visible:outline-primary"
>
<span className="material-symbols-outlined text-lg" aria-hidden="true">
route
</span>
{t("configure")}
</Link>
</div>
);
}

View File

@@ -0,0 +1,179 @@
// @vitest-environment jsdom
import React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { NextIntlClientProvider } from "next-intl";
import messages from "../../../src/i18n/messages/en.json";
import ReasoningRoutingRules from "../../../src/shared/components/ReasoningRoutingRules";
import RoutingPageClient from "../../../src/app/(dashboard)/dashboard/api-manager/routing/RoutingPageClient";
vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams() }));
const savedRule = {
id: "rule",
name: "Luna low",
description: "",
scope: "apiKey",
apiKeyId: "key",
comboId: null,
connectionId: null,
modelPattern: "gpt-5.6-luna",
sourceEffort: "any",
requestTags: ["coding"],
tagMatchMode: "all",
effortMode: "force",
targetEffort: "low",
targetKind: "keep",
targetModel: null,
targetComboId: null,
budgetAction: "preserve",
budgetTokens: null,
priority: 7,
enabled: true,
};
function setup(options: { failLoad?: boolean; failSave?: boolean } = {}) {
const writes: Record<string, unknown>[] = [];
let rules = [savedRule];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
if (url.includes("/reasoning-routing-rules") && init?.method === "PATCH") {
const payload = JSON.parse(String(init.body));
writes.push(payload);
if (options.failSave) return { ok: false, json: async () => ({ error: "Save failed" }) };
rules = [{ ...savedRule, ...payload }];
return { ok: true, json: async () => ({ success: true }) };
}
const data =
url === "/api/keys"
? {
keys: [
{ id: "key", name: "Example key" },
{ id: "second", name: "Second key" },
],
}
: url === "/api/combos"
? { combos: [{ id: "combo-id", name: "gpt-5.6-luna-combo" }] }
: url === "/api/providers"
? { connections: [] }
: url === "/api/models/catalog"
? { catalog: { codex: { models: [{ id: "gpt-5.6-luna" }] } } }
: { rules };
return { ok: !options.failLoad, json: async () => data };
})
);
render(
<NextIntlClientProvider locale="en" messages={messages}>
<ReasoningRoutingRules initialApiKeyId="key" />
</NextIntlClientProvider>
);
return writes;
}
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("isolated routing editor", () => {
it("uses the full dashboard width without a centered maximum-width wrapper", async () => {
setup();
cleanup();
const { container } = render(
<NextIntlClientProvider locale="en" messages={messages}>
<RoutingPageClient />
</NextIntlClientProvider>
);
await screen.findByText("Luna low");
const classes = Array.from(container.firstElementChild!.classList);
expect(classes).toContain("w-full");
expect(classes).toContain("min-w-0");
expect(classes.some((name) => name.startsWith("max-w-") || name === "mx-auto")).toBe(false);
});
it("round-trips existing patterns and advanced values without changing routing semantics", async () => {
const writes = setup();
fireEvent.click(await screen.findByRole("button", { name: "Edit" }));
expect(
(
screen.getByRole("textbox", {
name: "Exact request ID or pattern",
exact: true,
}) as HTMLInputElement
).value
).toBe(savedRule.modelPattern);
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(writes).toHaveLength(1));
expect(writes[0]).toMatchObject({
modelPattern: savedRule.modelPattern,
requestTags: ["coding"],
priority: 7,
tagMatchMode: "all",
effortMode: "force",
targetEffort: "low",
scope: "apiKey",
apiKeyId: "key",
});
});
it("stores a selected source combo NAME while keeping the key scope", async () => {
const writes = setup();
fireEvent.click(await screen.findByRole("button", { name: "Edit" }));
fireEvent.change(screen.getByLabelText("Match requests for"), { target: { value: "combo" } });
fireEvent.change(screen.getByRole("combobox", { name: "Source combo" }), {
target: { value: "gpt-5.6-luna-combo" },
});
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(writes).toHaveLength(1));
expect(writes[0]).toMatchObject({
modelPattern: "gpt-5.6-luna-combo",
comboId: null,
scope: "apiKey",
apiKeyId: "key",
});
});
it("keeps a dirty draft on failed save and blocks the saved-rule simulator", async () => {
setup({ failSave: true });
fireEvent.click(await screen.findByRole("button", { name: "Edit" }));
fireEvent.change(screen.getByRole("textbox", { name: "Name", exact: true }), {
target: { value: "Changed draft" },
});
expect(
(screen.getByRole("button", { name: "Simulate without upstream" }) as HTMLButtonElement)
.disabled
).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
await screen.findByText("Save failed");
expect(
(screen.getByRole("textbox", { name: "Name", exact: true }) as HTMLInputElement).value
).toBe("Changed draft");
});
it("asks before changing keys when the editor contains unsaved changes", async () => {
setup();
fireEvent.click(await screen.findByRole("button", { name: "Edit" }));
fireEvent.change(screen.getByRole("textbox", { name: "Name", exact: true }), {
target: { value: "Changed draft" },
});
fireEvent.change(screen.getByRole("combobox", { name: "API key" }), {
target: { value: "second" },
});
expect(screen.getByRole("dialog")).not.toBeNull();
fireEvent.click(screen.getByRole("dialog").querySelector("button")!);
expect((screen.getByRole("combobox", { name: "API key" }) as HTMLSelectElement).value).toBe(
"key"
);
});
it("does not offer writes after a failed configuration load", async () => {
setup({ failLoad: true });
await screen.findByRole("alert");
expect((screen.getByRole("button", { name: "New rule" }) as HTMLButtonElement).disabled).toBe(
true
);
expect(screen.queryByRole("button", { name: "Edit" })).toBeNull();
});
});