mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-22 06:42:19 +03:00
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:
1
changelog.d/features/13555-api-key-routing-editor.md
Normal file
1
changelog.d/features/13555-api-key-routing-editor.md
Normal 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
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
10
src/app/(dashboard)/dashboard/api-manager/routing/page.tsx
Normal file
10
src/app/(dashboard)/dashboard/api-manager/routing/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import RoutingPageClient from "./RoutingPageClient";
|
||||
|
||||
export default function ApiKeyRoutingPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<RoutingPageClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
58
src/shared/components/routing/RoutingChoice.tsx
Normal file
58
src/shared/components/routing/RoutingChoice.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/shared/components/routing/RoutingEntryLink.tsx
Normal file
29
src/shared/components/routing/RoutingEntryLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
179
tests/unit/ui/api-key-routing-editor.test.tsx
Normal file
179
tests/unit/ui/api-key-routing-editor.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user