feat(ui): select Laya for OSS classification (#43768)

This commit is contained in:
tin-berri 2026-10-02 12:17:55 -07:00 • committed by GitHub
parent b21e44cbf9
commit b1e0e9e84b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 280 additions and 150 deletions

View file

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

View file

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

View file

@ -136,7 +136,7 @@ export const AutoRouterLimits = () => {
<PopoverContent align="end" className="w-96 max-w-[calc(100vw-2rem)] gap-3">
<PopoverTitle>Routing and customization limits</PopoverTitle>
<p className="text-xs leading-5 text-muted-foreground">
Rule-based, Complexity, and Jev are unlimited with built-in settings. Choose or change tier models freely.
Rule-based, Complexity, and OSS are unlimited with built-in settings. Choose or change tier models freely.
Customization allowances are shared across this proxy.
</p>
<dl className="space-y-2 text-xs">

View file

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

View file

@ -16,6 +16,7 @@ import {
type ClassifierType,
type ComplexityRouterConfigValue,
} from "./ComplexityRouterConfig";
import { defaultJevClassifierConfig, normalizeJevClassifierConfig } from "./jev_classifier_config";
import { transitionClassifierType } from "./classifier_type_transition";
import { isForecastClassifier } from "./forecast_classifier_config";
import {
@ -148,6 +149,14 @@ const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({ val
if (next === "llm") changeType("llm");
if (next === "jev") changeType("jev");
};
const changeProvider = (provider: unknown) => {
if (provider !== "jev" && provider !== "laya") return;
const defaults = defaultJevClassifierConfig(provider);
onChange({
...value,
jev_classifier_config: { ...defaults, ...value.jev_classifier_config, provider, model: defaults.model },
});
};
const approachLabels: Partial<Record<ClassifierType, string>> = { capability: "Capability", llm_v2: "Fuse v2" };
const approachDescription: Partial<Record<ClassifierType, string>> = {
capability: "Use the efficient model when it is likely to succeed",
@ -164,7 +173,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: "OSS Classifier", description: "Use Jev or Laya to choose a tier" },
].map((option) => (
<Label
key={option.value}
@ -189,6 +198,25 @@ const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({ val
))}
</RadioGroup>
</fieldset>
{family === "jev" && (
<fieldset className="space-y-2">
<legend className="text-sm font-medium">OSS provider</legend>
<RadioGroup
value={normalizeJevClassifierConfig(value.jev_classifier_config).provider}
onValueChange={changeProvider}
className="flex gap-6"
>
<Label>
<RadioGroupItem value="jev" />
Jev
</Label>
<Label>
<RadioGroupItem value="laya" />
Laya
</Label>
</RadioGroup>
</fieldset>
)}
{family === "custom" && (
<p className="text-sm text-muted-foreground">This router uses a custom classifier plugin</p>
)}

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 OSS classifiers default to 3 turns. 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">OSS Classifier</strong>{" "}
<span className="text-muted-foreground">uses Jev or Laya 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 OSS classification method
</span>
)}
{editing && keywordRulesError && (

View file

@ -1,4 +1,5 @@
import React, { useState } from "react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, renderWithProviders, screen } from "../../../tests/test-utils";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
@ -96,49 +97,65 @@ function Form() {
describe("JEV classifier editor", () => {
afterEach(() => vi.mocked(useAuthorized).mockReset());
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();
expect(screen.getByLabelText("Jev Model")).toHaveValue("jev-latest");
expect(screen.getByLabelText("Jev Instructions")).toBeEnabled();
expect(screen.queryByLabelText("Judge model")).not.toBeInTheDocument();
expect(screen.queryByText("Reasoning Effort")).not.toBeInTheDocument();
expect(screen.queryByText("Classifier Prompt")).not.toBeInTheDocument();
expect(screen.queryByRole("switch", { name: "Use images for classification" })).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Jev Model"), { target: { value: "jev-test" } });
fireEvent.change(screen.getByLabelText("Jev Timeout (ms)"), { target: { value: "4200" } });
fireEvent.change(screen.getByLabelText("Context Window Size"), { target: { value: "6" } });
fireEvent.change(screen.getByLabelText("Circuit breaker cooldown (seconds)"), { target: { value: "50" } });
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.getByLabelText("Jev Model")).toHaveValue("jev-test");
expect(screen.getByLabelText("Jev Timeout (ms)")).toHaveValue(4200);
expect(screen.getByLabelText("Context Window Size")).toHaveValue("6");
expect(screen.getByRole("switch", { name: "Classifier circuit breaker" })).not.toBeChecked();
fireEvent.click(screen.getByRole("button", { name: "Probe current config" }));
expect(testAutoRouterRouting).toHaveBeenCalledWith(
"token",
expect.objectContaining({
complexity_router_config: expect.objectContaining({
classifier_type: "jev",
jev_classifier_config: {
model: "jev-test",
timeout_ms: 4200,
circuit_breaker_enabled: false,
circuit_breaker_cooldown_seconds: 50,
},
tiers: expect.objectContaining({ QUICK: ["fast"] }),
it.each(["jev", "laya"] as const)(
"preserves %s, custom tiers and context through save, reload and probe",
async (provider) => {
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: /^OSS Classifier$/ }));
expect(screen.getByRole("radio", { name: /^OSS Classifier$/ })).toBeChecked();
expect(screen.getByLabelText("Classifier Model")).toHaveValue("jev-latest");
expect(screen.getByLabelText("Classifier Instructions")).toBeEnabled();
expect(screen.queryByLabelText("Judge model")).not.toBeInTheDocument();
expect(screen.queryByText("Reasoning Effort")).not.toBeInTheDocument();
expect(screen.queryByText("Classifier Prompt")).not.toBeInTheDocument();
expect(screen.queryByRole("switch", { name: "Use images for classification" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("radio", { name: "Laya" }));
expect(screen.getByLabelText("Classifier Model")).toHaveTextContent("english");
fireEvent.click(screen.getByRole("radio", { name: "Jev" }));
expect(screen.getByLabelText("Classifier Model")).toHaveValue("jev-latest");
if (provider === "laya") {
fireEvent.click(screen.getByRole("radio", { name: "Laya" }));
await userEvent.click(screen.getByLabelText("Classifier Model"));
await userEvent.click(screen.getByRole("option", { name: "multilingual" }));
} else {
fireEvent.change(screen.getByLabelText("Classifier Model"), { target: { value: "jev-test" } });
}
fireEvent.change(screen.getByLabelText("Classifier Timeout (ms)"), { target: { value: "4200" } });
fireEvent.change(screen.getByLabelText("Context Window Size"), { target: { value: "6" } });
fireEvent.change(screen.getByLabelText("Circuit breaker cooldown (seconds)"), { target: { value: "50" } });
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: /^OSS Classifier$/ })).toBeChecked();
expect(screen.getByRole("radio", { name: provider === "laya" ? "Laya" : "Jev" })).toBeChecked();
if (provider === "laya") expect(screen.getByLabelText("Classifier Model")).toHaveTextContent("multilingual");
else expect(screen.getByLabelText("Classifier Model")).toHaveValue("jev-test");
expect(screen.getByLabelText("Classifier Timeout (ms)")).toHaveValue(4200);
expect(screen.getByLabelText("Context Window Size")).toHaveValue("6");
expect(screen.getByRole("switch", { name: "Classifier circuit breaker" })).not.toBeChecked();
fireEvent.click(screen.getByRole("button", { name: "Probe current config" }));
expect(testAutoRouterRouting).toHaveBeenCalledWith(
"token",
expect.objectContaining({
complexity_router_config: expect.objectContaining({
classifier_type: "oss_classifier",
opensource_classifier_config: {
provider,
model: provider === "laya" ? "multilingual" : "jev-test",
timeout_ms: 4200,
circuit_breaker_enabled: false,
circuit_breaker_cooldown_seconds: 50,
},
tiers: expect.objectContaining({ QUICK: ["fast"] }),
}),
}),
}),
);
});
);
},
);
it("allows licensed instructions and can restore built-in instructions", () => {
const authorized = useAuthorized();
@ -152,10 +169,10 @@ describe("JEV classifier editor", () => {
return <JevEditor value={value} onChange={setValue} />;
};
renderWithProviders(<LicensedForm />);
expect(screen.getByLabelText("Jev Instructions")).toBeEnabled();
fireEvent.change(screen.getByLabelText("Jev Instructions"), { target: { value: "New instructions" } });
expect(screen.getByLabelText("Jev Instructions")).toHaveValue("New instructions");
fireEvent.click(screen.getByRole("button", { name: "Restore built-in Jev instructions" }));
expect(screen.getByLabelText("Jev Instructions")).toHaveValue("");
expect(screen.getByLabelText("Classifier Instructions")).toBeEnabled();
fireEvent.change(screen.getByLabelText("Classifier Instructions"), { target: { value: "New instructions" } });
expect(screen.getByLabelText("Classifier Instructions")).toHaveValue("New instructions");
fireEvent.click(screen.getByRole("button", { name: "Restore built-in instructions" }));
expect(screen.getByLabelText("Classifier Instructions")).toHaveValue("");
});
});

View file

@ -6,7 +6,8 @@ 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { defaultJevClassifierConfig, LAYA_MODELS } from "./jev_classifier_config";
export default function JevClassifierConfig({
value,
@ -17,20 +18,38 @@ export default function JevClassifierConfig({
}) {
const id = useId();
const config = value.jev_classifier_config ?? defaultJevClassifierConfig();
const isLaya = config.provider === "laya";
const update = (patch: Partial<typeof config>) =>
onChange({ ...value, jev_classifier_config: { ...config, ...patch } });
return (
<div className="mt-4 space-y-3">
<p className="text-sm text-muted-foreground">
Uses TypeSafe System One Choice evaluation with your configured tiers
{isLaya
? "Uses Laya with your configured tiers. Set LAYA_API_BASE on the gateway to connect your Laya server."
: "Uses TypeSafe System One Choice evaluation with your configured tiers"}
</p>
<div>
<Label htmlFor={`${id}-model`}>Jev Model</Label>
<Input id={`${id}-model`} value={config.model} onChange={(event) => update({ model: event.target.value })} />
<Label htmlFor={`${id}-model`}>Classifier Model</Label>
{isLaya ? (
<Select value={config.model} onValueChange={(model) => model && update({ model })}>
<SelectTrigger id={`${id}-model`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LAYA_MODELS.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<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`}>Classifier Timeout (ms)</Label>
<Input
id={`${id}-timeout`}
type="number"
@ -50,7 +69,7 @@ export default function JevClassifierConfig({
}
/>
<div>
<Label htmlFor={`${id}-instructions`}>Jev Instructions</Label>
<Label htmlFor={`${id}-instructions`}>Classifier Instructions</Label>
<AutoRouterAllowanceNote
feature="tier_or_classifier_prompt"
label="Custom instructions share the custom-tier allowance"
@ -63,11 +82,11 @@ export default function JevClassifierConfig({
/>
{config.instructions && (
<Button variant="outline" type="button" onClick={() => update({ instructions: undefined })}>
Restore built-in Jev instructions
Restore built-in 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 OSS classification is available without a license and uses the shipped tier criteria
</p>
</div>
</div>

View file

@ -107,10 +107,10 @@ describe("JEV network probes", () => {
expect(JSON.parse(String(routingCall?.[1]?.body))).toEqual(expectedRequest);
expect(fetchMock).toHaveBeenCalledTimes(5);
expect(screen.getAllByTestId("test-status-success")).toHaveLength(4);
expect(screen.getByRole("status", { name: "Jev connection" })).toHaveTextContent(
expect(screen.getByRole("status", { name: "OSS classifier connection" })).toHaveTextContent(
cause === "jev_classifier"
? "Jev classification succeeded"
: `Jev was not reached successfully (routing cause: ${cause})`,
? "OSS classification succeeded"
: `OSS classifier was not reached successfully (routing cause: ${cause})`,
);
},
);
@ -131,7 +131,7 @@ describe("JEV network probes", () => {
);
fireEvent.change(screen.getByTestId("auto-router-routing-test-prompt"), { target: { value: "Hello" } });
fireEvent.click(screen.getByTestId("auto-router-routing-test-send"));
expect(await screen.findByText("JEV classifier")).toBeInTheDocument();
expect(await screen.findByText("OSS classifier")).toBeInTheDocument();
expect(screen.getByText("jev-latest")).toBeInTheDocument();
expect(screen.getByText("80.0%")).toBeInTheDocument();
expect(screen.getByText("SIMPLE: 80.0%")).toBeInTheDocument();

View file

@ -274,13 +274,13 @@ 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: "OSS Classifier" }));
await waitFor(() =>
expect(apiClient.post).toHaveBeenLastCalledWith(
"/auto_router/availability",
expect.objectContaining({
body: expect.objectContaining({
complexity_router_config: expect.objectContaining({ classifier_type: "jev" }),
complexity_router_config: expect.objectContaining({ classifier_type: "oss_classifier" }),
}),
}),
),
@ -301,13 +301,13 @@ 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: "OSS Classifier" })).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());
const saved = vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0].complexity_router_config;
expect(saved).not.toHaveProperty("tier_definitions");
expect(saved?.classifier_type).toBe("jev");
expect(saved?.classifier_type).toBe("oss_classifier");
expect(Object.keys(saved?.tiers ?? {})).toEqual(["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]);
expect(saved?.tiers).toEqual(initialRequest.complexity_router_config.tiers);
});
@ -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: "OSS Classifier" }));
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", "OSS Classifier"])(
"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",
@ -1902,7 +1907,7 @@ describe("preset catalog fetch states", () => {
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalledOnce());
expect(vi.mocked(handleAddAutoRouterSubmit).mock.calls[0][0].complexity_router_config).toMatchObject({
classifier_type: "jev",
classifier_type: "oss_classifier",
classifier_context_per_turn_chars: 450,
});
});

View file

@ -50,7 +50,7 @@ const AutoRouterConnectionTest: React.FC<AutoRouterConnectionTestProps> = ({
? { status: "success" }
: {
status: "error",
error: `Jev was not reached successfully (routing cause: ${decision.cause ?? "unknown"})`,
error: `OSS classifier was not reached successfully (routing cause: ${decision.cause ?? "unknown"})`,
},
);
};
@ -91,11 +91,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="OSS classifier connection" className="rounded-lg border p-3 text-sm">
<strong>OSS Classifier</strong>
<p>
{jevResult.status === "pending" && "Testing Jev classification"}
{jevResult.status === "success" && "Jev classification succeeded"}
{jevResult.status === "pending" && "Testing OSS classification"}
{jevResult.status === "success" && "OSS classification succeeded"}
{jevResult.status === "error" && jevResult.error}
</p>
</div>

View file

@ -33,20 +33,20 @@ describe("buildAutoRouterRoutingTestRequest", () => {
const expectedRequest = {
prompt: JEV_CONNECTION_TEST_PROMPT,
complexity_router_config: {
classifier_type: "jev",
classifier_type: "oss_classifier",
tiers: CONFIG.tiers,
jev_classifier_config: defaultJevClassifierConfig(),
opensource_classifier_config: defaultJevClassifierConfig(),
},
saved_model_id: "saved-id",
};
expect(request).toEqual(expectedRequest);
expect(request?.complexity_router_config.jev_classifier_config).not.toHaveProperty("api_key");
expect(request?.complexity_router_config.jev_classifier_config).not.toHaveProperty("api_base");
expect(request?.complexity_router_config.opensource_classifier_config).not.toHaveProperty("api_key");
expect(request?.complexity_router_config.opensource_classifier_config).not.toHaveProperty("api_base");
});
it.each(["object", "json"])("probes saved JEV %s configuration with custom tiers and team context", (format) => {
it.each(["object", "json"])("probes saved Laya %s configuration with custom tiers and team context", (format) => {
const config = {
classifier_type: "jev",
jev_classifier_config: { model: "jev-test", timeout_ms: 900 },
classifier_type: "oss_classifier",
opensource_classifier_config: { provider: "laya", model: "english", timeout_ms: 900 },
tiers: { QUICK: ["fast"], DEEP: ["strong"] },
tier_definitions: { QUICK: "Simple questions", DEEP: "Complex questions" },
fallback_tier: "DEEP",

View file

@ -1,7 +1,7 @@
import { AutoRouterRoutingTestRequest } from "../networking";
import { ComplexityRouterConfigPayload } from "./build_complexity_router_config";
import { z } from "zod";
import { jevClassifierConfigSchema } from "./jev_classifier_config";
import { hydrateOssClassifier, jevClassifierConfigSchema, normalizeJevClassifierConfig } from "./jev_classifier_config";
export const JEV_CONNECTION_TEST_PROMPT = "What is 2 plus 2?";
@ -23,16 +23,23 @@ export const buildSavedJevConnectionTestRequest = (
: rawConfig;
const result = z
.object({
classifier_type: z.literal("jev"),
classifier_type: z.enum(["jev", "oss_classifier"]),
tiers: z.record(z.unknown()),
jev_classifier_config: jevClassifierConfigSchema.default({}),
jev_classifier_config: jevClassifierConfigSchema.optional(),
opensource_classifier_config: jevClassifierConfigSchema.optional(),
})
.passthrough()
.safeParse(parsed);
if (!result.success) return undefined;
const { jev_classifier_config, opensource_classifier_config, ...config } = result.data;
const classifier = hydrateOssClassifier({ ...config, jev_classifier_config, opensource_classifier_config });
return {
prompt: JEV_CONNECTION_TEST_PROMPT,
complexity_router_config: result.data,
complexity_router_config: {
...config,
classifier_type: "oss_classifier",
opensource_classifier_config: normalizeJevClassifierConfig(classifier.jev_classifier_config),
},
saved_model_id: savedModelId,
...(teamId && { team_id: teamId }),
};

View file

@ -64,6 +64,7 @@ describe("buildComplexityRouterConfig", () => {
it.each([
{ model: "" },
{ model: " " },
{ provider: "laya" as const, model: "unsupported" },
{ timeout_ms: 0 },
{ timeout_ms: 1.5 },
{ timeout_ms: Number.NaN },
@ -75,15 +76,16 @@ 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 valid classifier 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) => {
it.each([false, true])("serializes Laya with shared context and no LLM config, custom tiers: %s", (custom) => {
const params: BuildComplexityRouterConfigParams = {
...baseParams,
classifierType: "jev",
jevClassifierConfig: {
model: "jev-test",
provider: "laya",
model: "english",
timeout_ms: 4500,
instructions: " Choose the configured tier ",
circuit_breaker_enabled: false,
@ -108,15 +110,17 @@ describe("buildComplexityRouterConfig", () => {
}),
};
const config = buildComplexityRouterConfig(params);
expect(config.classifier_type).toBe("jev");
expect(config.classifier_type).toBe("oss_classifier");
const expectedJevConfig = {
model: "jev-test",
provider: "laya",
model: "english",
timeout_ms: 4500,
instructions: "Choose the configured tier",
circuit_breaker_enabled: false,
circuit_breaker_cooldown_seconds: 12.5,
};
expect(config.jev_classifier_config).toEqual(expectedJevConfig);
expect(config.opensource_classifier_config).toEqual(expectedJevConfig);
expect(config).not.toHaveProperty("jev_classifier_config");
expect(config.classifier_context_window_size).toBe(4);
expect(config.classifier_context_budget_chars).toBe(2000);
expect(config.classifier_context_per_turn_chars).toBe(450);
@ -139,15 +143,15 @@ describe("buildComplexityRouterConfig", () => {
classifierType: "jev",
jevClassifierConfig: { model: "jev-latest", timeout_ms: 3000, instructions: " " },
});
expect(jev.jev_classifier_config).toEqual({ model: "jev-latest", timeout_ms: 3000 });
expect(jev.opensource_classifier_config).toEqual({ provider: "jev", model: "jev-latest", timeout_ms: 3000 });
const llmParams: BuildComplexityRouterConfigParams = {
...baseParams,
classifierType: "llm",
classifierLlmConfig: { model: "judge", timeout_ms: 1000 },
jevClassifierConfig: jev.jev_classifier_config,
jevClassifierConfig: jev.opensource_classifier_config,
};
const llm = buildComplexityRouterConfig(llmParams);
expect(llm).not.toHaveProperty("jev_classifier_config");
expect(llm).not.toHaveProperty("opensource_classifier_config");
});
it("forwards preset references and explicit overrides without materializing absent text on create", () => {

View file

@ -153,12 +153,13 @@ export interface StoredComplexityRouterConfig {
heuristic_first_max_tier?: unknown;
hybrid_boundary_margin?: unknown;
tier_labels?: unknown;
classifier_type?: ClassifierType;
classifier_type?: ClassifierType | "oss_classifier";
heuristic_v2_success_threshold?: unknown;
capability_classifier_config?: unknown;
llm_v2_config?: unknown;
classifier_llm_config?: ClassifierLLMConfig;
jev_classifier_config?: unknown;
opensource_classifier_config?: unknown;
classifier_context_window_size?: unknown;
classifier_context_budget_chars?: unknown;
classifier_context_per_turn_chars?: unknown;
@ -283,12 +284,13 @@ export interface ComplexityRouterConfigPayload {
default_model?: string;
plan_mode_min_tier?: string;
tier_labels?: ComplexityTierLabels;
classifier_type: ClassifierType;
classifier_type: ClassifierType | "oss_classifier";
heuristic_v2_success_threshold?: number;
capability_classifier_config?: CapabilitySettings;
llm_v2_config?: FuseSettings;
classifier_llm_config?: ClassifierLLMConfig;
jev_classifier_config?: JevClassifierConfig;
opensource_classifier_config?: JevClassifierConfig;
classifier_context_window_size?: number;
classifier_context_budget_chars?: number;
classifier_context_per_turn_chars?: number;
@ -438,7 +440,9 @@ 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";
return parsed.success
? null
: "Enter a valid classifier 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
@ -498,7 +502,7 @@ export const customTierWireFields = (
tiers: Object.fromEntries(rows.map((row) => [activeTierName(row), row.models])),
tier_definitions: tierDefinitionsFromRows(rows),
...(fallback && { fallback_tier: activeTierName(fallback) }),
classifier_type: classifierType === "jev" ? "jev" : "llm",
classifier_type: classifierType === "jev" ? "oss_classifier" : "llm",
// Rebuilt from the fields an edited tier set allows. The backend rejects system_prompt and
// classification_rubric beside tier_definitions, and both live inside this object rather than at
// the top level the omit list covers. The opening instructions ride classification_prompt below.
@ -769,8 +773,8 @@ export const buildComplexityRouterConfig = ({
...(defaultModel?.trim() && { default_model: defaultModel }),
...(planModeMinTier?.trim() && { plan_mode_min_tier: planModeMinTier }),
...(cleanedTierLabels && { tier_labels: cleanedTierLabels }),
classifier_type: classifierType,
...(effectiveType === "jev" && { jev_classifier_config: normalizeJevClassifierConfig(jevClassifierConfig) }),
classifier_type: classifierType === "jev" ? "oss_classifier" : classifierType,
...(effectiveType === "jev" && { opensource_classifier_config: normalizeJevClassifierConfig(jevClassifierConfig) }),
...(heuristicV2SuccessThreshold !== undefined && {
heuristic_v2_success_threshold: heuristicV2SuccessThreshold,
}),

View file

@ -1,7 +1,11 @@
import { z } from "zod";
import type { ClassifierType } from "./classifier_types";
export const LAYA_MODELS = ["english", "multilingual", "typed-decisions"] as const;
const jevClassifierConfigFields = {
model: z.string().trim().min(1).default("jev-latest"),
provider: z.preprocess((value) => (value === "typesafe" ? "jev" : value), z.enum(["jev", "laya"]).optional()),
model: z.string().trim().min(1).optional(),
timeout_ms: z.number().int().positive().default(3000),
instructions: z
.string()
@ -11,15 +15,39 @@ const jevClassifierConfigFields = {
circuit_breaker_cooldown_seconds: z.number().finite().positive().optional(),
};
export const jevClassifierConfigSchema = z.object(jevClassifierConfigFields);
export const jevClassifierConfigSchema = z
.object(jevClassifierConfigFields)
.transform((config) => ({
...config,
model: config.model ?? (config.provider === "laya" ? "english" : "jev-latest"),
}))
.refine((config) => config.provider !== "laya" || LAYA_MODELS.some((model) => model === config.model), {
message: "Select a supported Laya model",
path: ["model"],
});
export type JevClassifierConfig = z.infer<typeof jevClassifierConfigSchema>;
export const defaultJevClassifierConfig = (): JevClassifierConfig => jevClassifierConfigSchema.parse({});
export const defaultJevClassifierConfig = (provider: "jev" | "laya" = "jev"): JevClassifierConfig =>
jevClassifierConfigSchema.parse({ provider });
export const hydrateOssClassifier = (config: {
classifier_type?: ClassifierType | "oss_classifier";
opensource_classifier_config?: unknown;
jev_classifier_config?: unknown;
}): { classifier_type: ClassifierType; jev_classifier_config?: JevClassifierConfig } => ({
classifier_type: config.classifier_type === "oss_classifier" ? "jev" : config.classifier_type ?? "heuristic",
jev_classifier_config:
config.classifier_type === "oss_classifier" || config.classifier_type === "jev"
? jevClassifierConfigSchema.safeParse(config.opensource_classifier_config ?? config.jev_classifier_config ?? {})
.data ?? defaultJevClassifierConfig()
: undefined,
});
export const normalizeJevClassifierConfig = (
config: JevClassifierConfig = defaultJevClassifierConfig(),
): JevClassifierConfig => ({
provider: config.provider ?? "jev",
model: config.model.trim(),
timeout_ms: config.timeout_ms,
...(config.instructions?.trim() && { instructions: config.instructions.trim() }),

View file

@ -48,7 +48,7 @@ const hydratedState: KeywordMatchingState = {
};
describe("buildUpdatedComplexityRouterConfig keyword matching", () => {
it.each([false, true])("omits masked JEV credentials from dashboard saves, edited: %s", (edited) => {
it.each([false, true])("omits masked Jev credentials from legacy/canonical saves, edited: %s", (edited) => {
const stored = {
classifier_type: "jev" as const,
tiers: FORM_VALUE.tiers,
@ -60,7 +60,15 @@ describe("buildUpdatedComplexityRouterConfig keyword matching", () => {
api_base: "https://jev.example.com",
},
};
const hydrated = hydrateComplexityRouterConfig(stored, undefined);
const source = edited
? {
...stored,
classifier_type: "oss_classifier" as const,
jev_classifier_config: undefined,
opensource_classifier_config: { ...stored.jev_classifier_config, provider: "typesafe" },
}
: stored;
const hydrated = hydrateComplexityRouterConfig(source, undefined);
expect(hydrated.jev_classifier_config).not.toHaveProperty("api_key");
expect(hydrated.jev_classifier_config).not.toHaveProperty("api_base");
const value = edited
@ -69,8 +77,9 @@ describe("buildUpdatedComplexityRouterConfig keyword matching", () => {
jev_classifier_config: { model: "jev-updated", timeout_ms: 8100, instructions: "" },
}
: hydrated;
const saved = buildUpdatedComplexityRouterConfig(stored, value);
expect(saved.jev_classifier_config).toEqual({
const saved = buildUpdatedComplexityRouterConfig(source, value);
expect(saved.opensource_classifier_config).toEqual({
provider: "jev",
...(edited
? { model: "jev-updated", timeout_ms: 8100 }
: { model: "jev-configured", timeout_ms: 6100, instructions: "Existing instructions" }),
@ -78,7 +87,7 @@ describe("buildUpdatedComplexityRouterConfig keyword matching", () => {
for (const classifierType of ["llm", "heuristic"] as const) {
expect(
buildUpdatedComplexityRouterConfig(saved, transitionClassifierType(value, classifierType)),
).not.toHaveProperty("jev_classifier_config");
).not.toHaveProperty("opensource_classifier_config");
}
});
@ -94,19 +103,21 @@ describe("buildUpdatedComplexityRouterConfig keyword matching", () => {
tiers: FORM_VALUE.tiers,
};
const saved = buildUpdatedComplexityRouterConfig(stored, hydrateComplexityRouterConfig(stored, undefined));
expect(saved.jev_classifier_config).toEqual({
expect(saved.opensource_classifier_config).toEqual({
provider: "jev",
model: "jev-configured",
timeout_ms: 6100,
circuit_breaker_enabled: false,
});
});
it.each([false, true])("round trips JEV settings and preserves unmanaged fields, custom: %s", (custom) => {
it.each([false, true])("round trips Laya settings and preserves unmanaged fields, custom: %s", (custom) => {
const stored = {
...(custom ? storedCustomConfig() : STORED),
classifier_llm_config: { model: "stale-judge", timeout_ms: 3000 },
classifier_type: "jev" as const,
jev_classifier_config: {
model: "jev-test",
classifier_type: "oss_classifier" as const,
opensource_classifier_config: {
provider: "laya" as const,
model: "english",
timeout_ms: 4100,
instructions: "Judge the request",
circuit_breaker_enabled: false,
@ -121,12 +132,12 @@ describe("buildUpdatedComplexityRouterConfig keyword matching", () => {
const hydrated = hydrateComplexityRouterConfig(stored, undefined);
expect(effectiveClassifierType(hydrated)).toBe("jev");
expect(hydrated.classifier_llm_config).toBeUndefined();
expect(hydrated.jev_classifier_config).toEqual(stored.jev_classifier_config);
expect(hydrated.jev_classifier_config).toEqual(stored.opensource_classifier_config);
expect(hydrated.classifier_context_per_turn_chars).toBe(450);
const saved = buildUpdatedComplexityRouterConfig(stored, hydrated);
const expectedSavedConfig = {
classifier_type: "jev",
jev_classifier_config: stored.jev_classifier_config,
classifier_type: "oss_classifier",
opensource_classifier_config: stored.opensource_classifier_config,
classifier_context_window_size: 7,
classifier_context_budget_chars: 9000,
classifier_context_per_turn_chars: 450,
@ -135,12 +146,13 @@ describe("buildUpdatedComplexityRouterConfig keyword matching", () => {
};
expect(saved).toMatchObject(expectedSavedConfig);
expect(saved).not.toHaveProperty("classifier_llm_config");
expect(saved).not.toHaveProperty("jev_classifier_config");
const reloaded = hydrateComplexityRouterConfig(saved, undefined);
expect(reloaded.jev_classifier_config).toEqual(hydrated.jev_classifier_config);
expect(reloaded.classifier_context_per_turn_chars).toBe(450);
expect(effectiveClassifierType(reloaded)).toBe("jev");
const llm = buildUpdatedComplexityRouterConfig(saved, transitionClassifierType(reloaded, "llm"));
expect(llm).not.toHaveProperty("jev_classifier_config");
expect(llm).not.toHaveProperty("opensource_classifier_config");
});
it.each([0, 0.92, 1])("hydrates and saves a success threshold of %s without changing the artifact", (threshold) => {
@ -883,6 +895,7 @@ describe("managed keys survive an untouched open-and-save", () => {
"fallback_tier",
"hybrid_boundary_margin",
"jev_classifier_config",
"opensource_classifier_config",
"classifier_plugin_timeout_ms",
]);

View file

@ -101,6 +101,7 @@ export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
"llm_v2_config",
"classifier_llm_config",
"jev_classifier_config",
"opensource_classifier_config",
"classifier_context_window_size",
"classifier_context_budget_chars",
"classifier_context_include_assistant_turns",

View file

@ -1,4 +1,4 @@
import { defaultJevClassifierConfig, jevClassifierConfigSchema } from "../add_model/jev_classifier_config";
import { hydrateOssClassifier } from "../add_model/jev_classifier_config";
import { capabilitySettingsSchema, fuseSettingsSchema } from "../add_model/forecast_classifier_config";
import type { StoredComplexityRouterConfig } from "../add_model/build_complexity_router_config";
import {
@ -54,6 +54,7 @@ export const hydrateComplexityRouterConfig = (
parsedConfig: StoredComplexityRouterConfig,
complexityRouterDefaultModel: string | null | undefined,
): ComplexityRouterConfigValue => {
const classifier = hydrateOssClassifier(parsedConfig);
const builtIn = hydrateBuiltInTiers(parsedConfig.tiers, parsedConfig.enable_non_reasoning_tier);
const { tiers: hydratedTiers, enable_non_reasoning_tier } = builtIn;
const custom_tier_set = hydrateCustomTierSet(parsedConfig);
@ -70,19 +71,14 @@ export const hydrateComplexityRouterConfig = (
default_model: hydratePinnedDefaultModel(parsedConfig.default_model, complexityRouterDefaultModel, activeTiers),
plan_mode_min_tier: hydratePlanModeMinTier(parsedConfig.plan_mode_min_tier, custom_tier_set),
tier_labels: hydrateTierLabels(parsedConfig.tier_labels),
classifier_type: parsedConfig.classifier_type || "heuristic",
...classifier,
heuristic_v2_success_threshold:
typeof parsedConfig.heuristic_v2_success_threshold === "number"
? parsedConfig.heuristic_v2_success_threshold
: undefined,
capability_classifier_config: capabilitySettingsSchema.safeParse(parsedConfig.capability_classifier_config).data,
llm_v2_config: fuseSettingsSchema.safeParse(parsedConfig.llm_v2_config).data,
classifier_llm_config: parsedConfig.classifier_type === "jev" ? undefined : parsedConfig.classifier_llm_config,
jev_classifier_config:
parsedConfig.classifier_type === "jev"
? jevClassifierConfigSchema.safeParse(parsedConfig.jev_classifier_config ?? {}).data ??
defaultJevClassifierConfig()
: undefined,
classifier_llm_config: classifier.classifier_type === "jev" ? undefined : parsedConfig.classifier_llm_config,
classifier_context_window_size:
typeof parsedConfig.classifier_context_window_size === "number"
? parsedConfig.classifier_context_window_size

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 "OSS classifier";
case "literal_keyword_match":
case "keyword":
return matchedKeyword ? `Keyword match: "${matchedKeyword}"` : "Keyword match";

View file

@ -859,23 +859,28 @@ describe("autorouter_presets", () => {
});
describe("buildPresetPrefill", () => {
it("preserves JEV settings and drops inactive classifier settings when prefilling", () => {
it("preserves Laya settings and drops inactive classifier settings when prefilling", () => {
const config = {
tiers: { SIMPLE: ["fast"], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "jev" as const,
classifier_type: "oss_classifier" as const,
classification_mode: "every_request" as const,
session_affinity: false,
deployment_affinity: true,
modality_routing: false,
modality_pin_override: false,
jev_classifier_config: { model: "jev-test", timeout_ms: 4000, circuit_breaker_enabled: false },
opensource_classifier_config: {
provider: "laya" as const,
model: "english",
timeout_ms: 4000,
circuit_breaker_enabled: false,
},
classifier_llm_config: { model: "stale-judge", timeout_ms: 6000 },
classifier_context_window_size: 6,
};
const prefill = buildPresetPrefill(config, groupsOnly(["fast"]));
const expectedJevConfig = {
classifier_type: "jev",
jev_classifier_config: config.jev_classifier_config,
jev_classifier_config: config.opensource_classifier_config,
classifier_context_window_size: 6,
classifier_llm_config: undefined,
};

View file

@ -1,3 +1,4 @@
import { hydrateOssClassifier } from "@/components/add_model/jev_classifier_config";
import {
ComplexityRouterConfigPayload,
hydrateTierLabels,
@ -302,6 +303,7 @@ export const buildPresetPrefill = (
config: ComplexityRouterConfigPayload,
availability: ModelAvailability,
): PresetPrefill => {
const classifier = hydrateOssClassifier(config);
const resolve = (model: string): string => resolveAvailableModel(model, availability) ?? model;
const resolveTier = (models: string[]): string[] => models.map(resolve);
// Params key on the model name the preset spells while every tier entry is rewritten to the
@ -334,11 +336,10 @@ export const buildPresetPrefill = (
},
tier_model_params: resolveParamKeys(hydrateTierModelParams(config.tiers, config.tier_model_configs)),
tier_labels: hydrateTierLabels(config.tier_labels),
classifier_type: config.classifier_type,
...classifier,
heuristic_v2_success_threshold: config.heuristic_v2_success_threshold,
jev_classifier_config: config.classifier_type === "jev" ? config.jev_classifier_config : undefined,
classifier_llm_config:
config.classifier_type !== "jev" && config.classifier_llm_config
classifier.classifier_type !== "jev" && config.classifier_llm_config
? { ...config.classifier_llm_config, model: resolve(config.classifier_llm_config.model) }
: undefined,
classifier_context_window_size: config.classifier_context_window_size,