feat(ui): add Nimble decision classifier controls

This commit is contained in:
Tin Chi Lo 2026-09-29 14:47:50 -07:00
parent d2a574b791
commit eb3c616e2b
19 changed files with 344 additions and 44 deletions

View file

@ -85,7 +85,7 @@ describe("autoRouterRows", () => {
it.each([
["llm", "LLM Classifier"],
["jev", "JEV Classifier"],
["jev", "Decision Model"],
])("labels a router using the %s classifier", (classifierType, label) => {
const row = toAutoRouterRow(
{

View file

@ -57,7 +57,7 @@ const dedupe = (models: string[]): string[] => Array.from(new Set(models));
const COMPLEXITY_TYPE_LABELS: Record<string, string> = {
llm: "LLM Classifier",
jev: "JEV Classifier",
jev: "Decision Model",
capability: "Capability",
llm_v2: "Fuse v2",
heuristic_first: "Heuristic first",

View file

@ -68,7 +68,7 @@ describe("Auto-router classifier selection", () => {
llm: "LLM",
heuristic_first: "LLM",
hybrid: "LLM",
jev: "Jev",
jev: "Decision Model",
}[classifier_type];
expect(screen.getByRole("radio", { name: new RegExp(`^${family}$`) })).toBeChecked();
fireEvent.click(screen.getByRole("radio", { name: new RegExp(`^${family}$`) }));

View file

@ -164,7 +164,7 @@ const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({ val
{[
{ value: "heuristics", label: "Heuristics", description: "Classify locally, with no API call" },
{ value: "llm", label: "LLM", description: "Use a judge model to choose a solver" },
{ value: "jev", label: "Jev", description: "Use TypeSafe System One Choice to choose a tier" },
{ value: "jev", label: "Decision Model", description: "Use Jev or open-source Nimble to choose a tier" },
].map((option) => (
<Label
key={option.value}

View file

@ -642,8 +642,8 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
/>
<span className="text-xs text-muted-foreground">
Number of prior user turns sent to the classifier provider, excluding tool output and harness reminders.
LLM and Jev default to 3 turns; Jev sends them to the configured TypeSafe endpoint. Set to 0 to omit
conversation history. The current message and selected system text are still sent.
LLM and decision models default to 3 turns and send them to the configured classifier endpoint. Set to 0
to omit conversation history. The current message and selected system text are still sent.
</span>
</div>
<div>

View file

@ -54,8 +54,8 @@ const ClassifierTypeRadios: React.FC<ClassifierTypeRadiosProps> = ({ value, clas
<Label className="items-start font-normal leading-normal">
<RadioGroupItem value="jev" className="mt-0.5" />
<span>
<strong className="font-semibold">Jev Classifier</strong>{" "}
<span className="text-muted-foreground">uses TypeSafe System One Choice to decide the tier</span>
<strong className="font-semibold">Decision Model</strong>{" "}
<span className="text-muted-foreground">uses Jev or open-source Nimble to decide the tier</span>
</span>
</Label>
<SimpleTooltip content={scorerLockedReason}>

View file

@ -237,7 +237,7 @@ const TierSetToolbar: React.FC<{
{editing && (
<span className="block mt-1 text-xs text-muted-foreground">
Add or remove tiers to define your own set. Every custom tier needs a definition the classifier routes on, and
an edited set requires the LLM or Jev classification method
an edited set requires the LLM or Decision Model classification method
</span>
)}
{editing && keywordRulesError && (

View file

@ -96,14 +96,87 @@ function Form() {
describe("JEV classifier editor", () => {
afterEach(() => vi.mocked(useAuthorized).mockReset());
it("creates Nimble connection settings and retains the provider after save and reload", () => {
renderWithProviders(<Form />);
fireEvent.click(screen.getByRole("radio", { name: "Decision Model" }));
fireEvent.click(screen.getByRole("radio", { name: "Nimble (open source)" }));
expect(screen.getByLabelText("Nimble Model")).toHaveValue("nimble-latest");
fireEvent.change(screen.getByLabelText("API Base"), { target: { value: "https://nimble.example.com" } });
fireEvent.change(screen.getByLabelText("API Key"), { target: { value: "new-nimble-key" } });
fireEvent.change(screen.getByLabelText("Nimble Timeout (ms)"), { target: { value: "20000" } });
fireEvent.click(screen.getByRole("button", { name: "Probe current config" }));
expect(testAutoRouterRouting).toHaveBeenLastCalledWith(
"token",
expect.objectContaining({
complexity_router_config: expect.objectContaining({
classifier_type: "jev",
jev_classifier_config: {
provider: "bespoke_nimble",
model: "nimble-latest",
timeout_ms: 20000,
api_base: "https://nimble.example.com",
api_key: "new-nimble-key",
},
}),
}),
);
fireEvent.click(screen.getByRole("button", { name: "Save and reload" }));
expect(screen.getByRole("radio", { name: "Nimble (open source)" })).toBeChecked();
expect(screen.getByLabelText("Nimble Timeout (ms)")).toHaveValue(20000);
expect(screen.getByLabelText("API Key")).toHaveValue("");
fireEvent.click(screen.getByText("Connection settings"));
fireEvent.click(screen.getByRole("button", { name: "Clear saved API key" }));
fireEvent.click(screen.getByRole("button", { name: "Probe current config" }));
expect(testAutoRouterRouting).toHaveBeenLastCalledWith(
"token",
expect.objectContaining({
complexity_router_config: expect.objectContaining({
jev_classifier_config: {
provider: "bespoke_nimble",
model: "nimble-latest",
timeout_ms: 20000,
api_key: null,
},
}),
}),
);
fireEvent.click(screen.getByRole("button", { name: "Use gateway connection" }));
fireEvent.click(screen.getByRole("button", { name: "Probe current config" }));
expect(testAutoRouterRouting).toHaveBeenLastCalledWith(
"token",
expect.objectContaining({
complexity_router_config: expect.objectContaining({
jev_classifier_config: {
provider: "bespoke_nimble",
model: "nimble-latest",
timeout_ms: 20000,
api_key: null,
api_base: null,
},
}),
}),
);
});
it.each(["Internal User", "Admin Viewer", "org_admin"])("hides connection overrides for %s", (userRole) => {
vi.mocked(useAuthorized).mockReturnValue({ ...useAuthorized(), userRole });
renderWithProviders(<Form />);
fireEvent.click(screen.getByRole("radio", { name: "Decision Model" }));
fireEvent.click(screen.getByRole("radio", { name: "Nimble (open source)" }));
expect(screen.getByLabelText("Nimble Model")).toHaveValue("nimble-latest");
expect(screen.queryByText("Connection settings")).not.toBeInTheDocument();
expect(screen.queryByLabelText("API Key")).not.toBeInTheDocument();
expect(screen.queryByLabelText("API Base")).not.toBeInTheDocument();
});
it("uses built-in JEV without a license and preserves custom tiers and context through reload", () => {
renderWithProviders(<Form />);
expect(screen.getByLabelText("Judge model")).toBeInTheDocument();
expect(screen.getByText("Reasoning Effort")).toBeInTheDocument();
expect(screen.getByText("Classifier Prompt")).toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Use images for classification" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("radio", { name: /Jev Classifier/ }));
expect(screen.getByRole("radio", { name: /^Jev Classifier/ })).toBeChecked();
fireEvent.click(screen.getByRole("radio", { name: "Decision Model" }));
expect(screen.getByRole("radio", { name: "Decision Model" })).toBeChecked();
expect(screen.getByLabelText("Jev Model")).toHaveValue("jev-latest");
expect(screen.getByLabelText("Jev Instructions")).toBeEnabled();
expect(screen.queryByLabelText("Judge model")).not.toBeInTheDocument();
@ -117,7 +190,7 @@ describe("JEV classifier editor", () => {
fireEvent.click(screen.getByRole("switch", { name: "Classifier circuit breaker" }));
fireEvent.click(screen.getByRole("button", { name: "Customize tiers" }));
fireEvent.click(screen.getByRole("button", { name: "Save and reload" }));
expect(screen.getByRole("radio", { name: /Jev Classifier/ })).toBeChecked();
expect(screen.getByRole("radio", { name: "Decision Model" })).toBeChecked();
expect(screen.getByLabelText("Jev Model")).toHaveValue("jev-test");
expect(screen.getByLabelText("Jev Timeout (ms)")).toHaveValue(4200);
expect(screen.getByLabelText("Context Window Size")).toHaveValue("6");

View file

@ -6,7 +6,10 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import ClassifierCircuitBreakerConfig from "./ClassifierCircuitBreakerConfig";
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
import { defaultJevClassifierConfig } from "./jev_classifier_config";
import { defaultJevClassifierConfig, transitionDecisionModelProvider } from "./jev_classifier_config";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { isProxyAdminRole } from "@/utils/roles";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
export default function JevClassifierConfig({
value,
@ -16,21 +19,45 @@ export default function JevClassifierConfig({
onChange: (value: ComplexityRouterConfigValue) => void;
}) {
const id = useId();
const { userRole, isViewOnly } = useAuthorized();
const config = value.jev_classifier_config ?? defaultJevClassifierConfig();
const provider = config.provider ?? "typesafe";
const providerLabel = provider === "bespoke_nimble" ? "Nimble" : "Jev";
const update = (patch: Partial<typeof config>) =>
onChange({ ...value, jev_classifier_config: { ...config, ...patch } });
return (
<div className="mt-4 space-y-3">
<fieldset className="space-y-2">
<legend className="text-sm font-medium">Decision model provider</legend>
<RadioGroup
value={provider}
onValueChange={(next) => {
if (next === "typesafe" || next === "bespoke_nimble") {
onChange({ ...value, jev_classifier_config: transitionDecisionModelProvider(config, next) });
}
}}
className="flex flex-wrap gap-4"
>
<Label>
<RadioGroupItem value="typesafe" />
TypeSafe Jev
</Label>
<Label>
<RadioGroupItem value="bespoke_nimble" />
Nimble (open source)
</Label>
</RadioGroup>
</fieldset>
<p className="text-sm text-muted-foreground">
Uses TypeSafe System One Choice evaluation with your configured tiers
{providerLabel} selects a tier for each request using your configured tier criteria
</p>
<div>
<Label htmlFor={`${id}-model`}>Jev Model</Label>
<Label htmlFor={`${id}-model`}>{providerLabel} Model</Label>
<Input id={`${id}-model`} value={config.model} onChange={(event) => update({ model: event.target.value })} />
</div>
<div>
<Label htmlFor={`${id}-timeout`}>Jev Timeout (ms)</Label>
<Label htmlFor={`${id}-timeout`}>{providerLabel} Timeout (ms)</Label>
<Input
id={`${id}-timeout`}
type="number"
@ -40,6 +67,58 @@ export default function JevClassifierConfig({
onChange={(event) => update({ timeout_ms: Number(event.target.value) })}
/>
</div>
{isProxyAdminRole(userRole ?? "") && !isViewOnly && (
<details className="space-y-3 rounded-lg border p-3">
<summary className="cursor-pointer text-sm font-medium">Connection settings</summary>
<p className="text-xs text-muted-foreground">
Saved connection values are hidden. Untouched fields keep the saved connection. New routers use the gateway
connection when these fields are blank
</p>
<div>
<Label htmlFor={`${id}-api-base`}>API Base</Label>
<Input
id={`${id}-api-base`}
value={config.api_base ?? ""}
placeholder={provider === "bespoke_nimble" ? "https://nimble.example.com" : "https://api.typesafe.ai"}
onChange={(event) => update({ api_base: event.target.value })}
/>
</div>
<div>
<Label htmlFor={`${id}-api-key`}>API Key</Label>
<Input
id={`${id}-api-key`}
type="password"
autoComplete="new-password"
value={config.api_key ?? ""}
placeholder={
provider === "bespoke_nimble" ? "Optional for a keyless Nimble server" : "Enter the endpoint's key"
}
onChange={(event) => update({ api_key: event.target.value || null })}
/>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" type="button" onClick={() => update({ api_base: null, api_key: null })}>
Use gateway connection
</Button>
{provider === "bespoke_nimble" && (
<>
<Button variant="outline" type="button" onClick={() => update({ api_key: null })}>
Clear saved API key
</Button>
<p className="w-full text-xs text-muted-foreground">
Clearing the key keeps a saved custom endpoint and connects without authentication. A gateway
connection still uses its configured key. Use gateway connection clears both router overrides
</p>
</>
)}
</div>
{config.api_base === null && config.api_key === null && (
<p role="status" className="text-xs text-muted-foreground">
Gateway connection selected for the next save
</p>
)}
</details>
)}
<ClassifierCircuitBreakerConfig
value={config}
onChange={(next) =>
@ -50,7 +129,7 @@ export default function JevClassifierConfig({
}
/>
<div>
<Label htmlFor={`${id}-instructions`}>Jev Instructions</Label>
<Label htmlFor={`${id}-instructions`}>{providerLabel} Instructions</Label>
<AutoRouterAllowanceNote
feature="tier_or_classifier_prompt"
label="Custom instructions share the custom-tier allowance"
@ -63,11 +142,11 @@ export default function JevClassifierConfig({
/>
{config.instructions && (
<Button variant="outline" type="button" onClick={() => update({ instructions: undefined })}>
Restore built-in Jev instructions
Restore built-in {providerLabel} instructions
</Button>
)}
<p className="text-xs text-muted-foreground">
Built-in Jev is available without a license and uses the shipped tier criteria
Built-in decision model classification is available without a license and uses the shipped tier criteria
</p>
</div>
</div>

View file

@ -73,6 +73,41 @@ const response = (cause: string) => ({
afterEach(() => vi.unstubAllGlobals());
describe("JEV network probes", () => {
it.each(["jev_classifier", "classifier_fallback"])("labels Nimble probe results for %s", async (cause) => {
const fetchMock = vi.fn<typeof fetch>(async () => new Response(JSON.stringify(response(cause))));
vi.stubGlobal("fetch", fetchMock);
const nimbleRequest = buildSavedJevConnectionTestRequest(
{
...config,
jev_classifier_config: {
provider: "bespoke_nimble",
model: "nimble-latest",
timeout_ms: 20000,
api_key: "masked-secret",
api_base: "https://nimble.example.com",
},
},
"nimble-router",
);
renderWithProviders(<AutoRouterConnectionTest accessToken="token" targets={[]} jevRequest={nimbleRequest} />);
expect(
await screen.findByText(
cause === "jev_classifier"
? "Nimble classification succeeded"
: "Nimble was not reached successfully (routing cause: classifier_fallback)",
),
).toBeVisible();
expect(screen.getByRole("status", { name: "Nimble connection" })).toHaveTextContent("Nimble Classifier");
expect(JSON.parse(String(fetchMock.mock.calls[0][1]?.body))).toEqual({
prompt: JEV_CONNECTION_TEST_PROMPT,
saved_model_id: "nimble-router",
complexity_router_config: {
...config,
jev_classifier_config: { provider: "bespoke_nimble", model: "nimble-latest", timeout_ms: 20000 },
},
});
});
it.each(["jev_classifier", "classifier_fallback", "default_model_fallback", "keyword_match"])(
"probes the routing endpoint independently of tier models and checks the cause %s",
async (cause) => {

View file

@ -274,7 +274,7 @@ describe("AddAutoRouterTab", () => {
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
renderWithProviders(<Harness />);
await user.click(await screen.findByRole("button", { name: "Choose models for me" }));
await user.click(screen.getByRole("radio", { name: "Jev" }));
await user.click(screen.getByRole("radio", { name: "Decision Model" }));
await waitFor(() =>
expect(apiClient.post).toHaveBeenLastCalledWith(
"/auto_router/availability",
@ -301,7 +301,7 @@ describe("AddAutoRouterTab", () => {
expect(within(screen.getByRole("alert")).getByRole("link", { name: "Talk to our team" })).toBeVisible();
await user.click(screen.getByRole("button", { name: "Restore defaults" }));
await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
expect(screen.getByRole("radio", { name: "Jev" })).toBeChecked();
expect(screen.getByRole("radio", { name: "Decision Model" })).toBeChecked();
await waitFor(() => expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeEnabled());
await user.click(screen.getByRole("button", { name: "Add Auto Router" }));
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
@ -317,7 +317,7 @@ describe("AddAutoRouterTab", () => {
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
renderWithProviders(<Harness />);
await user.click(await screen.findByRole("button", { name: "Choose models for me" }));
await user.click(screen.getByRole("radio", { name: "Jev" }));
await user.click(screen.getByRole("radio", { name: "Decision Model" }));
fireEvent.change(screen.getByLabelText("Auto Router Name"), { target: { value: "checked-router" } });
await waitFor(() => expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeEnabled());
let complete: ((result: unknown) => void) | undefined;
@ -357,17 +357,22 @@ describe("AddAutoRouterTab", () => {
expect(screen.getByRole("button", { name: "Routing approach" })).toHaveTextContent("Complexity");
});
it.each(["LLM", "Jev"])("keeps %s and the frequency when choosing models automatically", async (family) => {
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
renderWithProviders(<Harness />);
const automatic = await screen.findByRole("button", { name: "Choose models for me" });
await userEvent.click(screen.getByRole("radio", { name: family }));
await selectAutoRouterOption("How often to classify", "Every new user message");
await userEvent.click(automatic);
expect(screen.getByRole("radio", { name: family })).toBeChecked();
expect(screen.getByRole("combobox", { name: "How often to classify" })).toHaveTextContent("Every new user message");
expect(screen.getByRole("button", { name: "Advanced settings" })).toHaveAttribute("aria-expanded", "false");
});
it.each(["LLM", "Decision Model"])(
"keeps %s and the frequency when choosing models automatically",
async (family) => {
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
renderWithProviders(<Harness />);
const automatic = await screen.findByRole("button", { name: "Choose models for me" });
await userEvent.click(screen.getByRole("radio", { name: family }));
await selectAutoRouterOption("How often to classify", "Every new user message");
await userEvent.click(automatic);
expect(screen.getByRole("radio", { name: family })).toBeChecked();
expect(screen.getByRole("combobox", { name: "How often to classify" })).toHaveTextContent(
"Every new user message",
);
expect(screen.getByRole("button", { name: "Advanced settings" })).toHaveAttribute("aria-expanded", "false");
},
);
it.each(["Capability", "Fuse v2"])(
"creates %s from its dedicated tab without complexity templates",

View file

@ -10,6 +10,7 @@ import {
import { AutoRouterTestTarget } from "./build_auto_router_test_targets";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { jevClassifierConfigSchema } from "./jev_classifier_config";
interface AutoRouterConnectionTestProps {
accessToken: string;
@ -31,6 +32,9 @@ const AutoRouterConnectionTest: React.FC<AutoRouterConnectionTestProps> = ({
jevRequest,
onTestComplete,
}) => {
const provider = jevClassifierConfigSchema.safeParse(jevRequest?.complexity_router_config.jev_classifier_config).data
?.provider;
const classifierLabel = provider === "bespoke_nimble" ? "Nimble" : "Jev";
const [results, setResults] = React.useState<TargetResult[]>(() => targets.map(() => ({ status: "pending" })));
const [jevResult, setJevResult] = React.useState<TargetResult>({ status: "pending" });
@ -50,7 +54,7 @@ const AutoRouterConnectionTest: React.FC<AutoRouterConnectionTestProps> = ({
? { status: "success" }
: {
status: "error",
error: `Jev was not reached successfully (routing cause: ${decision.cause ?? "unknown"})`,
error: `${classifierLabel} was not reached successfully (routing cause: ${decision.cause ?? "unknown"})`,
},
);
};
@ -91,11 +95,11 @@ const AutoRouterConnectionTest: React.FC<AutoRouterConnectionTestProps> = ({
classifier probe includes its reasoning effort override.
</p>
{jevRequest && (
<div role="status" aria-label="Jev connection" className="rounded-lg border p-3 text-sm">
<strong>Jev Classifier</strong>
<div role="status" aria-label={`${classifierLabel} connection`} className="rounded-lg border p-3 text-sm">
<strong>{classifierLabel} Classifier</strong>
<p>
{jevResult.status === "pending" && "Testing Jev classification"}
{jevResult.status === "success" && "Jev classification succeeded"}
{jevResult.status === "pending" && `Testing ${classifierLabel} classification`}
{jevResult.status === "success" && `${classifierLabel} classification succeeded`}
{jevResult.status === "error" && jevResult.error}
</p>
</div>

View file

@ -75,7 +75,7 @@ describe("buildComplexityRouterConfig", () => {
classifier_type: "jev",
jev_classifier_config: { model: "jev-latest", timeout_ms: 3000, ...patch },
}),
).toBe("Enter a JEV model, a positive whole-number timeout and a positive cooldown");
).toBe("Enter a decision model, a positive whole-number timeout and a positive cooldown");
});
it.each([false, true])("serializes JEV with shared context and no LLM config, custom tiers: %s", (custom) => {

View file

@ -8,7 +8,7 @@ import type { ModelGroup } from "../llm_calls/fetch_models";
import { KeywordTierRule } from "./KeywordTierRules";
import {
type JevClassifierConfig,
jevClassifierConfigSchema,
jevClassifierFormConfigSchema,
normalizeJevClassifierConfig,
} from "./jev_classifier_config";
import {
@ -437,8 +437,8 @@ export const getClassifierModelError = (
>,
): string | null => {
if (effectiveClassifierType(config) === "jev") {
const parsed = jevClassifierConfigSchema.safeParse(config.jev_classifier_config ?? {});
return parsed.success ? null : "Enter a JEV model, a positive whole-number timeout and a positive cooldown";
const parsed = jevClassifierFormConfigSchema.safeParse(config.jev_classifier_config ?? {});
return parsed.success ? null : "Enter a decision model, a positive whole-number timeout and a positive cooldown";
}
if (!usesLlmClassifier(effectiveClassifierType(config)) || config.classifier_llm_config?.model) return null;
return config.custom_tier_set

View file

@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
jevClassifierConfigSchema,
jevClassifierFormConfigSchema,
normalizeJevClassifierConfig,
transitionDecisionModelProvider,
} from "./jev_classifier_config";
const nimble = { provider: "bespoke_nimble" as const, model: "nimble-latest", timeout_ms: 3000 };
describe("decision model connections", () => {
it("keeps new connection overrides but strips saved credentials on hydration", () => {
const supplied = { ...nimble, api_base: " https://nimble.example.com ", api_key: " new-key " };
expect(normalizeJevClassifierConfig(jevClassifierFormConfigSchema.parse(supplied))).toEqual({
...nimble,
api_base: "https://nimble.example.com",
api_key: "new-key",
});
expect(jevClassifierConfigSchema.parse(supplied)).toEqual(nimble);
});
it.each([
[{ api_base: "", api_key: " " }, {}],
[{ api_key: null }, { api_key: null }],
[
{ api_base: null, api_key: null },
{ api_base: null, api_key: null },
],
])("distinguishes untouched fields from explicit connection resets: %j", (input, expected) => {
expect(normalizeJevClassifierConfig({ ...nimble, ...input })).toEqual({ ...nimble, ...expected });
});
it("drops credentials when changing providers and keeps tuning settings", () => {
const jev = { model: "custom-jev", timeout_ms: 9000, api_base: "https://jev.example.com", api_key: "jev-key" };
const switched = transitionDecisionModelProvider(jev, "bespoke_nimble");
expect(switched).toEqual({ ...nimble, timeout_ms: 9000 });
expect(transitionDecisionModelProvider({ ...switched, api_key: "nimble-key" }, "typesafe")).toEqual({
provider: "typesafe",
model: "jev-latest",
timeout_ms: 9000,
});
expect(transitionDecisionModelProvider(jev, "typesafe")).toBe(jev);
});
});

View file

@ -1,6 +1,7 @@
import { z } from "zod";
const jevClassifierConfigFields = {
provider: z.enum(["typesafe", "bespoke_nimble"]).optional(),
model: z.string().trim().min(1).default("jev-latest"),
timeout_ms: z.number().int().positive().default(3000),
instructions: z
@ -13,15 +14,36 @@ const jevClassifierConfigFields = {
export const jevClassifierConfigSchema = z.object(jevClassifierConfigFields);
export type JevClassifierConfig = z.infer<typeof jevClassifierConfigSchema>;
export const jevClassifierFormConfigSchema = z.object({
...jevClassifierConfigFields,
api_base: z.string().trim().nullish(),
api_key: z.string().trim().nullish(),
});
export type JevClassifierConfig = z.infer<typeof jevClassifierFormConfigSchema>;
export type DecisionModelProvider = NonNullable<JevClassifierConfig["provider"]>;
export const defaultJevClassifierConfig = (): JevClassifierConfig => jevClassifierConfigSchema.parse({});
export const transitionDecisionModelProvider = (
config: JevClassifierConfig,
provider: DecisionModelProvider,
): JevClassifierConfig => {
if ((config.provider ?? "typesafe") === provider) return config;
const { api_base, api_key, ...settings } = config;
return { ...settings, provider, model: provider === "bespoke_nimble" ? "nimble-latest" : "jev-latest" };
};
export const normalizeJevClassifierConfig = (
config: JevClassifierConfig = defaultJevClassifierConfig(),
): JevClassifierConfig => ({
...(config.provider !== undefined && { provider: config.provider }),
model: config.model.trim(),
timeout_ms: config.timeout_ms,
...(config.api_base === null && { api_base: null }),
...(config.api_base?.trim() && { api_base: config.api_base.trim() }),
...(config.api_key === null && { api_key: null }),
...(config.api_key?.trim() && { api_key: config.api_key.trim() }),
...(config.instructions?.trim() && { instructions: config.instructions.trim() }),
...(config.circuit_breaker_enabled !== undefined && { circuit_breaker_enabled: config.circuit_breaker_enabled }),
...(config.circuit_breaker_cooldown_seconds !== undefined && {

View file

@ -48,6 +48,35 @@ const hydratedState: KeywordMatchingState = {
};
describe("buildUpdatedComplexityRouterConfig keyword matching", () => {
it("preserves Nimble through dashboard validation and saves without exposing transport credentials", () => {
const stored = {
classifier_type: "jev" as const,
tiers: FORM_VALUE.tiers,
jev_classifier_config: {
provider: "bespoke_nimble" as const,
model: "nimble-latest",
timeout_ms: 6100,
api_key: "masked-key",
api_base: "https://nimble.example.com",
},
};
const hydrated = hydrateComplexityRouterConfig(stored, undefined);
expect(hydrated.jev_classifier_config).toEqual({
provider: "bespoke_nimble",
model: "nimble-latest",
timeout_ms: 6100,
});
const edited = {
...hydrated,
jev_classifier_config: { ...hydrated.jev_classifier_config!, timeout_ms: 900 },
};
expect(buildUpdatedComplexityRouterConfig(stored, edited).jev_classifier_config).toEqual({
provider: "bespoke_nimble",
model: "nimble-latest",
timeout_ms: 900,
});
});
it.each([false, true])("omits masked JEV credentials from dashboard saves, edited: %s", (edited) => {
const stored = {
classifier_type: "jev" as const,

View file

@ -28,6 +28,15 @@ const forecast = {
};
describe("RoutingDecisionCard", () => {
it.each([
["bespoke_nimble/nimble-latest", "Nimble classifier"],
["typesafe/jev-latest", "JEV classifier"],
])("labels a decision from %s", (classifier_model, label) => {
render(<RoutingDecisionCard decision={{ cause: "jev_classifier", classifier_model, tier: "SIMPLE" }} />);
expect(screen.getByText(label)).toBeInTheDocument();
expect(screen.getByText(classifier_model)).toBeInTheDocument();
});
it("renders nothing when the request carried no routing decision", () => {
const { container } = render(<RoutingDecisionCard decision={undefined} />);
expect(container).toBeEmptyDOMElement();

View file

@ -143,7 +143,7 @@ function describeCause(decision: RoutingDecision): string {
case "llm_classifier":
return classifierModel ? `LLM classifier (${classifierModel})` : "LLM classifier";
case "jev_classifier":
return "JEV classifier";
return classifierModel?.startsWith("bespoke_nimble/") ? "Nimble classifier" : "JEV classifier";
case "literal_keyword_match":
case "keyword":
return matchedKeyword ? `Keyword match: "${matchedKeyword}"` : "Keyword match";