feat(ui): complete JEV auto router configuration and connection probes

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Moe Khalil 2026-09-18 20:07:07 +00:00
parent 86e079d7a8
commit 969cde4f0c
29 changed files with 978 additions and 113 deletions

View file

@ -83,13 +83,16 @@ describe("autoRouterRows", () => {
expect(row.targets).toEqual(["gpt-4o-mini", "anthropic-sonnet-4-6"]);
});
it("labels a router using the LLM classifier", () => {
it.each([
["llm", "LLM Classifier"],
["jev", "JEV Classifier"],
])("labels a router using the %s classifier", (classifierType, label) => {
const row = toAutoRouterRow(
{
...complexityDeployment,
litellm_params: {
...complexityDeployment.litellm_params,
complexity_router_config: { tiers: {}, classifier_type: "llm", adaptive: true },
complexity_router_config: { tiers: {}, classifier_type: classifierType, adaptive: true },
},
},
0,
@ -97,7 +100,7 @@ describe("autoRouterRows", () => {
null,
);
expect(row.typeLabel).toBe("LLM Classifier");
expect(row.typeLabel).toBe(label);
});
it("treats a deployment carrying complexity_router_config as complexity even off the canonical model string", () => {

View file

@ -57,6 +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",
capability: "Capability",
llm_v2: "Fuse v2",
heuristic_first: "Heuristic first",

View file

@ -1,4 +1,5 @@
import { transitionClassifierType } from "./classifier_type_transition";
import JevClassifierConfig from "./JevClassifierConfig";
import { Info } from "lucide-react";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { MultiSelect } from "@/components/shared/MultiSelect";
@ -37,6 +38,7 @@ import {
effectiveTierLabel,
heuristicScoringRole,
usesLlmClassifier,
usesClassifierContext,
DEFAULT_HYBRID_BOUNDARY_MARGIN,
HEURISTIC_FIRST_MAX_TIER_KEYS,
effectiveClassifierType,
@ -208,6 +210,13 @@ const ClassifierTypeRadios: React.FC<{
<span className="text-muted-foreground">calls a model to decide the tier (e.g. a small/fast model)</span>
</span>
</Label>
<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>
</span>
</Label>
<SimpleTooltip content={scorerLockedReason}>
<Label className="items-start font-normal leading-normal has-data-disabled:cursor-not-allowed has-data-disabled:opacity-50">
<RadioGroupItem value="heuristic_first" className="mt-0.5" disabled={scorerLocked} />
@ -499,6 +508,7 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
</p>
</div>
{classifierType === "jev" && <JevClassifierConfig value={value} onChange={onChange} />}
{usesLlmClassifier(classifierType) && (
<div className="mt-4 space-y-3">
<div>
@ -591,6 +601,10 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
/>
)}
</div>
</div>
)}
{usesClassifierContext(classifierType) && (
<div className="mt-4 space-y-3">
<RestrictedSection heading="If the classifier fails" by={restrictedBy(value, "classifierFallback")}>
<RadioGroup
value={value.classifier_fallback ?? DEFAULT_CLASSIFIER_FALLBACK}

View file

@ -1,4 +1,7 @@
import RoutingOptions from "./RoutingOptions";
import type { JevClassifierConfig } from "./jev_classifier_config";
import { type ClassifierType } from "./classifier_types";
export { type ClassifierType, usesLlmClassifier, usesClassifierContext } from "./classifier_types";
import PlanModeOverrideControls from "./PlanModeOverrideControls";
import ForecastClassifierConfig, { ForecastSolverModels } from "./ForecastClassifierConfig";
import { isForecastClassifier, type CapabilitySettings, type FuseSettings } from "./forecast_classifier_config";
@ -147,23 +150,6 @@ export interface ClassifierLLMConfig {
system_prompt?: string;
}
export type ClassifierType =
| "heuristic"
| "heuristic_v2"
| "llm"
| "heuristic_first"
| "hybrid"
| "capability"
| "llm_v2";
/**
* Whether this router can call classifier_llm_config.model. Mirrors the backend's
* ComplexityRouterConfig.uses_llm_classifier, and is the single gate for every classifier-only
* control and payload key, so a new chaining type cannot strip knobs the operator set.
*/
export const usesLlmClassifier = (classifierType: ClassifierType): boolean =>
(["llm", "heuristic_first", "hybrid", "capability", "llm_v2"] as const).some((type) => type === classifierType);
export type ClassifierFallback = "heuristic" | "default_model";
export const DEFAULT_CLASSIFIER_FALLBACK: ClassifierFallback = "heuristic";
@ -200,7 +186,7 @@ export const heuristicScoringRole = (value: ComplexityRouterConfigValue): Heuris
// Derived, never written into the value, so undoing a tier edit reverts the form with nothing left behind.
export const effectiveClassifierType = (
value: Pick<ComplexityRouterConfigValue, "custom_tier_set" | "classifier_type">,
): ClassifierType => (value.custom_tier_set ? "llm" : value.classifier_type);
): ClassifierType => (value.custom_tier_set && value.classifier_type !== "jev" ? "llm" : value.classifier_type);
const rowOrigin = (row: TierRow, editing: boolean): string => {
if (!editing) return row.id;
@ -251,8 +237,8 @@ const TierSetToolbar: React.FC<{
</div>
{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 LLM classifier routes on,
and an edited set requires the LLM classification method
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
</span>
)}
{editing && keywordRulesError && (
@ -271,7 +257,7 @@ const FallbackTierField: React.FC<{
<div className="mt-4">
<div className="flex items-center gap-2 mb-2">
<strong className="text-base font-semibold">Fallback Tier</strong>
<SimpleTooltip content="Where requests route when the LLM classifier errors, times out, or returns an unparseable reply. Required for an edited tier set: the heuristic scorer cannot produce your tiers.">
<SimpleTooltip content="Where requests route when the classifier errors, times out, or returns an unparseable reply. Required for an edited tier set: the heuristic scorer cannot produce your tiers">
<Info className="size-4 text-muted-foreground" />
</SimpleTooltip>
</div>
@ -377,6 +363,7 @@ export interface ComplexityRouterConfigValue {
capability_classifier_config?: CapabilitySettings;
llm_v2_config?: FuseSettings;
classifier_llm_config?: ClassifierLLMConfig;
jev_classifier_config?: JevClassifierConfig;
classifier_context_window_size?: number;
classifier_context_budget_chars?: number;
classifier_context_per_turn_chars?: number;
@ -641,7 +628,11 @@ const ComplexityRouterConfig: React.FC<ComplexityRouterConfigProps> = ({
<Card>
<CardContent>
{!customTierSet && (
<NonReasoningTierToggle value={value} onChange={onChange} available={value.classifier_type === "llm"} />
<NonReasoningTierToggle
value={value}
onChange={onChange}
available={value.classifier_type === "llm" || value.classifier_type === "jev"}
/>
)}
{tierRows.map((row, index) => {

View file

@ -0,0 +1,158 @@
import React, { useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, renderWithProviders, screen } from "../../../tests/test-utils";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import ClassificationMethodConfig from "./ClassificationMethodConfig";
import AutoRouterClassifierTabs from "./AutoRouterClassifierTabs";
import JevEditor from "./JevClassifierConfig";
import { type ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
import {
buildUpdatedComplexityRouterConfig,
hydrateComplexityRouterConfig,
} from "../edit_auto_router/edit_auto_router_modal";
import { applyTierSetAction } from "./tier_set_actions";
import { testAutoRouterRouting } from "../networking";
import { buildSavedJevConnectionTestRequest } from "./build_auto_router_routing_test_request";
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: vi.fn(() => ({
isLoading: false,
isAuthorized: true,
token: "token",
accessToken: "token",
userId: "user",
userEmail: "user@example.com",
userRole: "Admin",
userRoleLabel: "Admin",
isViewOnly: false,
premiumUser: false,
disabledPersonalKeyCreation: false,
showSSOBanner: false,
})),
}));
vi.mock("@/components/networking", async (importOriginal) => ({
...(await importOriginal<typeof import("@/components/networking")>()),
getComplexityScorerDefaults: vi.fn(async () => ({
tier_boundaries: {},
token_thresholds: {},
dimension_weights: {},
})),
testAutoRouterRouting: vi.fn(async () => ({ status: "error", error: "fixture" })),
}));
const initial: ComplexityRouterConfigValue = {
classifier_type: "llm",
classifier_llm_config: { model: "judge", timeout_ms: 1000 },
tiers: { SIMPLE: ["fast"], MEDIUM: ["mid"], COMPLEX: ["strong"], REASONING: ["reasoner"] },
};
function Form() {
const [value, setValue] = useState(initial);
return (
<AutoRouterClassifierTabs value={value} onChange={setValue}>
<ClassificationMethodConfig
value={value}
onChange={setValue}
modelOptions={[{ value: "judge", label: "judge" }]}
effortOptionsByModel={{ judge: ["low"] }}
customTechnicalKeywords={[]}
onCustomTechnicalKeywordsChange={() => {}}
/>
<button
onClick={() =>
setValue(
applyTierSetAction(value, [], {
kind: "patch",
id: "SIMPLE",
patch: { name: "QUICK", definition: "Quick tasks" },
}).value,
)
}
>
Customize tiers
</button>
<button
onClick={() =>
setValue(hydrateComplexityRouterConfig(buildUpdatedComplexityRouterConfig({}, value), undefined))
}
>
Save and reload
</button>
<button
onClick={() => {
const request = buildSavedJevConnectionTestRequest(buildUpdatedComplexityRouterConfig({}, value));
if (request) void testAutoRouterRouting("token", request);
}}
>
Probe current config
</button>
</AutoRouterClassifierTabs>
);
}
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("Classifier 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("tab", { name: "Complexity" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByLabelText("JEV Model")).toHaveValue("jev-latest");
expect(screen.getByLabelText("JEV Instructions")).toBeDisabled();
expect(screen.queryByLabelText("Classifier 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("allows licensed instructions and can restore built-in instructions", () => {
const authorized = useAuthorized();
vi.mocked(useAuthorized).mockReturnValue({ ...authorized, premiumUser: true });
const LicensedForm = () => {
const [value, setValue] = useState<ComplexityRouterConfigValue>({
...initial,
classifier_type: "jev",
jev_classifier_config: { model: "jev-latest", timeout_ms: 3000, instructions: "Existing instructions" },
});
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("");
});
});

View file

@ -0,0 +1,88 @@
import React, { useId } from "react";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { SimpleTooltip } from "@/components/ui/tooltip";
import ClassifierCircuitBreakerConfig from "./ClassifierCircuitBreakerConfig";
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
import { defaultJevClassifierConfig } from "./jev_classifier_config";
export default function JevClassifierConfig({
value,
onChange,
}: {
value: ComplexityRouterConfigValue;
onChange: (value: ComplexityRouterConfigValue) => void;
}) {
const id = useId();
const { premiumUser } = useAuthorized();
const config = value.jev_classifier_config ?? defaultJevClassifierConfig();
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
</p>
<div>
<Label htmlFor={`${id}-model`}>JEV 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>
<Input
id={`${id}-timeout`}
type="number"
min={1}
step={1}
value={config.timeout_ms}
onChange={(event) => update({ timeout_ms: Number(event.target.value) })}
/>
</div>
<ClassifierCircuitBreakerConfig
value={config}
onChange={(next) =>
update({
circuit_breaker_enabled: next.circuit_breaker_enabled,
circuit_breaker_cooldown_seconds: next.circuit_breaker_cooldown_seconds,
})
}
/>
<div>
<Label htmlFor={`${id}-instructions`}>JEV Instructions</Label>
<SimpleTooltip
content={!premiumUser ? "Custom JEV instructions require a LiteLLM Enterprise license" : undefined}
>
<div>
<Textarea
id={`${id}-instructions`}
value={config.instructions ?? ""}
disabled={!premiumUser}
placeholder="Leave blank to use the built-in instructions"
onChange={(event) => update({ instructions: event.target.value || undefined })}
/>
</div>
</SimpleTooltip>
{config.instructions && (
<Button variant="outline" type="button" onClick={() => update({ instructions: undefined })}>
Restore built-in JEV instructions
</Button>
)}
<p className="text-xs text-muted-foreground">
Built-in JEV is available without a license and uses the shipped tier criteria
{!premiumUser && (
<>
. Custom instructions require LiteLLM Enterprise. Get a trial key{" "}
<a href="https://www.litellm.ai/#pricing" target="_blank" rel="noopener noreferrer" className="underline">
here
</a>
</>
)}
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,148 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
import AutoRouterConnectionTest from "./auto_router_connection_test";
import AutoRouterRoutingTest from "./AutoRouterRoutingTest";
import { buildAutoRouterTestTargets } from "./build_auto_router_test_targets";
import {
buildSavedJevConnectionTestRequest,
JEV_CONNECTION_TEST_PROMPT,
} from "./build_auto_router_routing_test_request";
import { buildComplexityRouterConfig } from "./build_complexity_router_config";
vi.mock(
"@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults",
async () => await import("../../../tests/mocks/complexityScorerDefaults"),
);
const config = buildComplexityRouterConfig({
classifierType: "jev",
jevClassifierConfig: { model: "jev-latest", timeout_ms: 3000 },
tiers: { SIMPLE: ["fast"], MEDIUM: ["mid"], COMPLEX: ["strong"], REASONING: ["reasoner"] },
defaultModel: undefined,
planModeMinTier: undefined,
tierLabels: undefined,
classifierLlmConfig: undefined,
classifierContextWindowSize: undefined,
classifierContextBudgetChars: undefined,
classifierContextIncludeAssistantTurns: undefined,
classifierFallback: undefined,
classificationPrompt: undefined,
classificationExamples: undefined,
heuristicFirstMaxTier: undefined,
classificationMode: undefined,
sessionAffinity: false,
deploymentAffinity: true,
customTechnicalKeywords: [],
keywordTierRules: [],
semanticMatchingEnabled: false,
embeddingModel: undefined,
matchThreshold: 0.5,
escalationKeywords: [],
adaptive: false,
adaptiveWeights: { quality: 0.3, cost: 0.7 },
tierDistancePenalty: 0.5,
adaptiveEligible: "all",
returnRawModelName: false,
});
const request = buildSavedJevConnectionTestRequest(JSON.stringify(config), "fast", "my-router");
const targets = buildAutoRouterTestTargets({
tiers: Object.entries(config.tiers),
semanticMatchingEnabled: false,
embeddingModel: undefined,
});
const response = (cause: string) => ({
routed_model: "fast",
routed_model_configured: true,
routing_decision: {
cause,
tier: "SIMPLE",
classifier_model: "jev-latest",
classifier_confidence: 0.8,
classifier_probabilities: { SIMPLE: 0.8, REASONING: 0.2 },
classifier_cost: 0.00001234,
},
});
afterEach(() => vi.unstubAllGlobals());
describe("JEV network probes", () => {
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) => {
const fetchMock = vi.fn<typeof fetch>(
async (input) =>
new Response(JSON.stringify(String(input).endsWith("/auto_router/test_routing") ? response(cause) : {})),
);
vi.stubGlobal("fetch", fetchMock);
const onTestComplete = vi.fn();
renderWithProviders(
<AutoRouterConnectionTest
accessToken="test-token"
targets={targets}
jevRequest={request}
onTestComplete={onTestComplete}
/>,
);
await waitFor(() => expect(onTestComplete).toHaveBeenCalledOnce());
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining("/auto_router/test_routing"),
expect.objectContaining({
method: "POST",
body: expect.any(String),
}),
);
const routingCall = fetchMock.mock.calls.find(([url]) => String(url).endsWith("/auto_router/test_routing"));
expect(JSON.parse(String(routingCall?.[1]?.body))).toEqual({
prompt: JEV_CONNECTION_TEST_PROMPT,
complexity_router_config: config,
default_model: "fast",
router_name: "my-router",
});
expect(fetchMock).toHaveBeenCalledTimes(5);
expect(screen.getAllByTestId("test-status-success")).toHaveLength(4);
expect(screen.getByRole("status", { name: "JEV connection" })).toHaveTextContent(
cause === "jev_classifier"
? "JEV classification succeeded"
: `JEV was not reached successfully (routing cause: ${cause})`,
);
},
);
it("shows routing diagnostics from the real networking response", async () => {
vi.stubGlobal(
"fetch",
vi.fn<typeof fetch>(async () => new Response(JSON.stringify(response("jev_classifier")))),
);
renderWithProviders(
<AutoRouterRoutingTest
accessToken="token"
config={config}
defaultModel="fast"
routerName="router"
teamId={undefined}
/>,
);
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(screen.getByText("jev-latest")).toBeInTheDocument();
expect(screen.getByText("80.0%")).toBeInTheDocument();
expect(screen.getByText("SIMPLE: 80.0%")).toBeInTheDocument();
expect(screen.getByText("REASONING: 20.0%")).toBeInTheDocument();
expect(screen.getByText("$0.00001234")).toBeInTheDocument();
});
it("reports a classifier endpoint error while still checking downstream models", async () => {
vi.stubGlobal(
"fetch",
vi.fn<typeof fetch>(async (input) =>
String(input).endsWith("/auto_router/test_routing")
? new Response(JSON.stringify({ detail: "JEV classifier unavailable" }), { status: 503 })
: new Response("{}"),
),
);
renderWithProviders(<AutoRouterConnectionTest accessToken="token" targets={targets} jevRequest={request} />);
expect(await screen.findByText("JEV classifier unavailable")).toBeInTheDocument();
expect(screen.getAllByTestId("test-status-success")).toHaveLength(4);
});
});

View file

@ -39,7 +39,7 @@ const NonReasoningTierToggle: React.FC<{
<span className="block text-xs text-muted-foreground">
Adds NON_REASONING below Simple, for operational agent traffic that relays or reformats information rather than
reasoning about it. Escalation still moves up out of it when a request needs more.
{!available && " Requires the LLM classification method."}
{!available && " Requires the LLM or JEV classification method"}
</span>
<Separator className="my-4" />
</>

View file

@ -4,6 +4,9 @@ import { type ComplexityRouterConfigValue, heuristicScoringRole, usesLlmClassifi
import { restrictedBy } from "./TierRestrictions";
const tierConfigIntroText = (value: ComplexityRouterConfigValue): string => {
if (value.classifier_type === "jev") {
return "JEV classifies each request with TypeSafe System One Choice evaluation and routes it to a tier. Configure which models handle each tier";
}
if (value.classifier_type === "heuristic_v2") {
return "The complexity router classifies each request with a calibrated local four-tier model (no API calls). Configure which model(s) handle each tier.";
}

View file

@ -57,7 +57,11 @@ import {
import { activeTierName, activeTierRows, getCustomTierRowsError, resolveComplexityDefaultModel } from "./tier_rows";
import { tierRowLabel } from "./complexity_router_tiers";
import { buildAutoRouterTestTargets, AutoRouterTestTarget } from "./build_auto_router_test_targets";
import AutoRouterConnectionTest from "./auto_router_connection_test";
import { AutoRouterConnectionTestDialog } from "./auto_router_connection_test";
import {
buildAutoRouterRoutingTestRequest,
JEV_CONNECTION_TEST_PROMPT,
} from "./build_auto_router_routing_test_request";
import AutoRouterRoutingTest from "./AutoRouterRoutingTest";
import { toast } from "@/lib/toast";
import {
@ -405,6 +409,7 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
classificationMode: complexityRouterConfig.classification_mode,
tierLabels: complexityRouterConfig.tier_labels,
classifierType: complexityRouterConfig.classifier_type,
jevClassifierConfig: complexityRouterConfig.jev_classifier_config,
capabilityClassifierConfig: complexityRouterConfig.capability_classifier_config,
llmV2Config: complexityRouterConfig.llm_v2_config,
classifierLlmConfig: complexityRouterConfig.classifier_llm_config,
@ -839,41 +844,31 @@ const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
</DialogContent>
</Dialog>
<Dialog
<AutoRouterConnectionTestDialog
open={isTestModalVisible}
onOpenChange={(open) => {
if (!open) {
setIsTestModalVisible(false);
setIsTestingConnection(false);
}
onClose={() => {
setIsTestModalVisible(false);
setIsTestingConnection(false);
}}
>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[700px]">
<DialogHeader>
<DialogTitle>Connection Test Results</DialogTitle>
</DialogHeader>
{isTestModalVisible && (
<AutoRouterConnectionTest
key={connectionTestId}
accessToken={accessToken}
targets={testTargets}
onTestComplete={() => setIsTestingConnection(false)}
/>
)}
<DialogFooter>
{" "}
<Button
variant="outline"
onClick={() => {
setIsTestModalVisible(false);
setIsTestingConnection(false);
}}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
testId={connectionTestId}
accessToken={accessToken}
targets={testTargets}
jevRequest={
effectiveClassifierType(complexityRouterConfig) === "jev"
? buildAutoRouterRoutingTestRequest({
prompt: JEV_CONNECTION_TEST_PROMPT,
config: buildComplexityRouterConfig(complexityRouterConfigParams),
defaultModel: resolveComplexityDefaultModel(
complexityRouterConfig,
complexityRouterConfig.default_model,
),
routerName: watchedName,
teamId: requiresTeamScope ? watchedTeamId ?? undefined : undefined,
})
: undefined
}
onTestComplete={() => setIsTestingConnection(false)}
/>
</TooltipProvider>
);
};

View file

@ -1,12 +1,20 @@
import React from "react";
import { CircleCheck, CircleX, LoaderCircle } from "lucide-react";
import { testModelGroupConnection, ModelGroupConnectionResult } from "../networking";
import {
testModelGroupConnection,
ModelGroupConnectionResult,
testAutoRouterRouting,
AutoRouterRoutingTestRequest,
} from "../networking";
import { AutoRouterTestTarget } from "./build_auto_router_test_targets";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
interface AutoRouterConnectionTestProps {
accessToken: string;
targets: AutoRouterTestTarget[];
jevRequest?: AutoRouterRoutingTestRequest;
onTestComplete?: () => void;
}
@ -20,15 +28,36 @@ const cleanErrorMessage = (error: string): string => {
const AutoRouterConnectionTest: React.FC<AutoRouterConnectionTestProps> = ({
accessToken,
targets,
jevRequest,
onTestComplete,
}) => {
const [results, setResults] = React.useState<TargetResult[]>(() => targets.map(() => ({ status: "pending" })));
const [jevResult, setJevResult] = React.useState<TargetResult>({ status: "pending" });
React.useEffect(() => {
let cancelled = false;
const probeJev = async () => {
if (!jevRequest) return;
const response = await testAutoRouterRouting(accessToken, jevRequest);
if (cancelled) return;
if (response.status === "error") {
setJevResult(response);
return;
}
const decision = response.result.routing_decision;
setJevResult(
decision.cause === "jev_classifier"
? { status: "success" }
: {
status: "error",
error: `JEV was not reached successfully (routing cause: ${decision.cause ?? "unknown"})`,
},
);
};
const run = async () => {
await Promise.all(
targets.map(async (target, index) => {
await Promise.all([
probeJev(),
...targets.map(async (target, index) => {
const result = target.requestParams
? await testModelGroupConnection(accessToken, target.modelGroup, target.mode, target.requestParams)
: await testModelGroupConnection(accessToken, target.modelGroup, target.mode);
@ -37,7 +66,7 @@ const AutoRouterConnectionTest: React.FC<AutoRouterConnectionTestProps> = ({
result.status === "error" ? { status: "error", error: cleanErrorMessage(result.error) } : result;
setResults((prev) => prev.map((r, i) => (i === index ? cleaned : r)));
}),
);
]);
if (!cancelled && onTestComplete) onTestComplete();
};
run();
@ -47,7 +76,7 @@ const AutoRouterConnectionTest: React.FC<AutoRouterConnectionTestProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps -- probes run once per mount; the parent remounts via `key` to start a fresh test, and re-running on prop identity changes would refire paid requests
}, []);
if (targets.length === 0) {
if (targets.length === 0 && !jevRequest) {
return (
<p className="text-sm text-muted-foreground">
No complexity tiers are configured yet, so there is nothing to test.
@ -61,6 +90,16 @@ const AutoRouterConnectionTest: React.FC<AutoRouterConnectionTestProps> = ({
Test Connection sends a minimal request to every configured tier, classifier, default, and embedding model. The
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>
<p>
{jevResult.status === "pending" && "Testing JEV classification"}
{jevResult.status === "success" && "JEV classification succeeded"}
{jevResult.status === "error" && jevResult.error}
</p>
</div>
)}
{targets.map((target, index) => {
const result = results[index] ?? { status: "pending" };
return (
@ -100,3 +139,26 @@ const AutoRouterConnectionTest: React.FC<AutoRouterConnectionTestProps> = ({
};
export default AutoRouterConnectionTest;
export function AutoRouterConnectionTestDialog({
open,
onClose,
testId,
...props
}: AutoRouterConnectionTestProps & { open: boolean; onClose: () => void; testId: number }) {
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[700px]">
<DialogHeader>
<DialogTitle>Connection Test Results</DialogTitle>
</DialogHeader>
{open && <AutoRouterConnectionTest key={testId} {...props} />}
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -1,4 +1,9 @@
import { buildAutoRouterRoutingTestRequest } from "./build_auto_router_routing_test_request";
import { describe, expect, it } from "vitest";
import {
buildAutoRouterRoutingTestRequest,
buildSavedJevConnectionTestRequest,
JEV_CONNECTION_TEST_PROMPT,
} from "./build_auto_router_routing_test_request";
import { ComplexityRouterConfigPayload } from "./build_complexity_router_config";
const CONFIG = {
@ -15,6 +20,36 @@ const params = {
};
describe("buildAutoRouterRoutingTestRequest", () => {
it.each(["object", "json"])("probes saved JEV %s configuration with custom tiers and team context", (format) => {
const config = {
classifier_type: "jev",
jev_classifier_config: { model: "jev-test", timeout_ms: 900 },
tiers: { QUICK: ["fast"], DEEP: ["strong"] },
tier_definitions: { QUICK: "Simple questions", DEEP: "Complex questions" },
fallback_tier: "DEEP",
classifier_context_window_size: 4,
};
expect(
buildSavedJevConnectionTestRequest(
format === "json" ? JSON.stringify(config) : config,
"strong",
"saved-router",
"team-1",
),
).toEqual({
prompt: JEV_CONNECTION_TEST_PROMPT,
complexity_router_config: config,
default_model: "strong",
router_name: "saved-router",
team_id: "team-1",
});
});
it.each([undefined, null, "not json", "[]", {}, { classifier_type: "llm", tiers: {} }, { classifier_type: "jev" }])(
"does not build a JEV probe for invalid or other classifier configurations: %j",
(config) => {
expect(buildSavedJevConnectionTestRequest(config)).toBeUndefined();
},
);
it("sends the prompt with the config being edited", () => {
const request = buildAutoRouterRoutingTestRequest(params);

View file

@ -1,5 +1,38 @@
import { AutoRouterRoutingTestRequest } from "../networking";
import { ComplexityRouterConfigPayload } from "./build_complexity_router_config";
import { z } from "zod";
export const JEV_CONNECTION_TEST_PROMPT = "What is 2 plus 2?";
export const buildSavedJevConnectionTestRequest = (
rawConfig: unknown,
defaultModel?: string,
routerName?: string,
teamId?: string,
): AutoRouterRoutingTestRequest | undefined => {
const parsed: unknown =
typeof rawConfig === "string"
? (() => {
try {
return JSON.parse(rawConfig) as unknown;
} catch {
return undefined;
}
})()
: rawConfig;
const result = z
.object({ classifier_type: z.literal("jev"), tiers: z.record(z.unknown()) })
.passthrough()
.safeParse(parsed);
if (!result.success) return undefined;
return {
prompt: JEV_CONNECTION_TEST_PROMPT,
complexity_router_config: result.data,
...(defaultModel && { default_model: defaultModel }),
...(routerName && { router_name: routerName }),
...(teamId && { team_id: teamId }),
};
};
export interface BuildAutoRouterRoutingTestRequestParams {
prompt: string;

View file

@ -1,3 +1,4 @@
import { describe, expect, it } from "vitest";
import {
buildComplexityRouterConfig,
getPlanModeTierError,
@ -24,6 +25,11 @@ const tiers = {
const baseParams: BuildComplexityRouterConfigParams = {
tiers,
defaultModel: undefined,
planModeMinTier: undefined,
classificationExamples: undefined,
heuristicFirstMaxTier: undefined,
classificationMode: undefined,
tierLabels: undefined,
classifierType: "heuristic",
classifierLlmConfig: undefined,
@ -48,6 +54,94 @@ const baseParams: BuildComplexityRouterConfigParams = {
};
describe("buildComplexityRouterConfig", () => {
it("accepts built-in JEV defaults without an LLM classifier model", () => {
expect(getClassifierModelError({ classifier_type: "jev" })).toBeNull();
});
it.each([
{ model: "" },
{ model: " " },
{ timeout_ms: 0 },
{ timeout_ms: 1.5 },
{ timeout_ms: Number.NaN },
{ circuit_breaker_cooldown_seconds: -1 },
{ circuit_breaker_cooldown_seconds: Number.POSITIVE_INFINITY },
])("rejects invalid JEV settings before saving or testing: %j", (patch) => {
expect(
getClassifierModelError({
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");
});
it.each([false, true])("serializes JEV with shared context and no LLM config, custom tiers: %s", (custom) => {
const config = buildComplexityRouterConfig({
...baseParams,
classifierType: "jev",
jevClassifierConfig: {
model: "jev-test",
timeout_ms: 4500,
instructions: " Choose the configured tier ",
circuit_breaker_enabled: false,
circuit_breaker_cooldown_seconds: 12.5,
},
classifierLlmConfig: { model: "stale", timeout_ms: 30 },
classificationPrompt: "stale prompt",
classificationExamples: "stale examples",
classifierContextWindowSize: 4,
classifierContextBudgetChars: 2000,
classifierContextIncludeAssistantTurns: true,
classifierFallback: "default_model",
...(custom && {
customTierSet: {
tiers: [
{ id: "quick", name: "QUICK", definition: "Short answers", models: ["fast"] },
{ id: "review", name: "REVIEW", definition: "Deep review", models: ["strong"] },
],
fallback_tier_id: "quick",
},
}),
});
expect(config.classifier_type).toBe("jev");
expect(config.jev_classifier_config).toEqual({
model: "jev-test",
timeout_ms: 4500,
instructions: "Choose the configured tier",
circuit_breaker_enabled: false,
circuit_breaker_cooldown_seconds: 12.5,
});
expect(config.classifier_context_window_size).toBe(4);
expect(config.classifier_context_budget_chars).toBe(2000);
expect(config.classifier_context_include_assistant_turns).toBe(true);
expect(config).not.toHaveProperty("classifier_llm_config");
expect(config).not.toHaveProperty("classification_prompt");
expect(config).not.toHaveProperty("classification_examples");
if (custom) {
expect(config.tiers).toEqual({ QUICK: ["fast"], REVIEW: ["strong"] });
expect(config.fallback_tier).toBe("QUICK");
} else {
expect(config.classifier_fallback).toBe("default_model");
expect(config.tiers).toEqual(tiers);
}
});
it("omits blank JEV instructions and ignores stale JEV settings when saving LLM", () => {
const jev = buildComplexityRouterConfig({
...baseParams,
classifierType: "jev",
jevClassifierConfig: { model: "jev-latest", timeout_ms: 3000, instructions: " " },
});
expect(jev.jev_classifier_config).toEqual({ model: "jev-latest", timeout_ms: 3000 });
const llm = buildComplexityRouterConfig({
...baseParams,
classifierType: "llm",
classifierLlmConfig: { model: "judge", timeout_ms: 1000 },
jevClassifierConfig: jev.jev_classifier_config,
});
expect(llm).not.toHaveProperty("jev_classifier_config");
});
it.each(["capability", "llm_v2", "heuristic"] as const)(
"disables the removed overrides only for forecast creates: %s",
(classifierType) => {

View file

@ -6,6 +6,11 @@ import {
} from "./forecast_classifier_config";
import type { ModelGroup } from "../llm_calls/fetch_models";
import { KeywordTierRule } from "./KeywordTierRules";
import {
type JevClassifierConfig,
jevClassifierConfigSchema,
normalizeJevClassifierConfig,
} from "./jev_classifier_config";
import {
type CustomTierSet,
type TierRow,
@ -44,6 +49,7 @@ import {
effectiveTierLabel,
heuristicScoringRoleFor,
usesLlmClassifier,
usesClassifierContext,
} from "./ComplexityRouterConfig";
export type ClassifierVisionConfig = { enabled?: boolean; max_images?: number };
@ -133,7 +139,7 @@ const scorerKnobPayload = ({
};
export interface StoredComplexityRouterConfig {
tiers?: Partial<Record<keyof ComplexityTiers, unknown>>;
tiers?: Record<string, unknown>;
enable_non_reasoning_tier?: boolean;
tier_model_configs?: unknown;
default_model?: string | null;
@ -147,6 +153,7 @@ export interface StoredComplexityRouterConfig {
capability_classifier_config?: unknown;
llm_v2_config?: unknown;
classifier_llm_config?: ClassifierLLMConfig;
jev_classifier_config?: unknown;
classifier_context_window_size?: unknown;
classifier_context_budget_chars?: unknown;
classifier_context_include_assistant_turns?: unknown;
@ -185,6 +192,7 @@ export interface BuildComplexityRouterConfigParams {
capabilityClassifierConfig?: CapabilitySettings;
llmV2Config?: FuseSettings;
classifierLlmConfig: ClassifierLLMConfigWire | undefined;
jevClassifierConfig?: JevClassifierConfig;
classifierContextWindowSize: number | undefined;
classifierContextBudgetChars: number | undefined;
classifierContextIncludeAssistantTurns: boolean | undefined;
@ -251,6 +259,7 @@ export interface ComplexityRouterConfigPayload {
capability_classifier_config?: CapabilitySettings;
llm_v2_config?: FuseSettings;
classifier_llm_config?: ClassifierLLMConfig;
jev_classifier_config?: JevClassifierConfig;
classifier_context_window_size?: number;
classifier_context_budget_chars?: number;
classifier_context_per_turn_chars?: number;
@ -356,11 +365,16 @@ export const getKeywordTierRulesError = (
return `Keyword rule(s) ${orphaned.join(", ")} route to a tier this router no longer has`;
};
// An edited tier set forces the LLM classifier, so the model requirement follows the EFFECTIVE type.
// Both forms' submit gates and their submit handlers read this one answer so they cannot drift.
export const getClassifierModelError = (
config: Pick<ComplexityRouterConfigValue, "custom_tier_set" | "classifier_type" | "classifier_llm_config">,
config: Pick<
ComplexityRouterConfigValue,
"custom_tier_set" | "classifier_type" | "classifier_llm_config" | "jev_classifier_config"
>,
): 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";
}
if (!usesLlmClassifier(effectiveClassifierType(config)) || config.classifier_llm_config?.model) return null;
return config.custom_tier_set
? "Please select a classifier model: an edited tier set routes with the LLM classifier"
@ -395,6 +409,7 @@ export const getSemanticConfigError = ({
};
interface CustomTierWireFieldInputs {
classifierType?: ClassifierType;
classifierLlmConfig: ClassifierLLMConfigWire | undefined;
planModeMinTierId: string | undefined;
classificationPrompt: string | undefined;
@ -403,7 +418,13 @@ interface CustomTierWireFieldInputs {
export const customTierWireFields = (
customTierSet: CustomTierSet,
{ classifierLlmConfig, planModeMinTierId, classificationPrompt, classificationExamples }: CustomTierWireFieldInputs,
{
classifierType,
classifierLlmConfig,
planModeMinTierId,
classificationPrompt,
classificationExamples,
}: CustomTierWireFieldInputs,
): Partial<ComplexityRouterConfigPayload> => {
const rows = customTierSet.tiers;
const fallback = tierRowById(rows, customTierSet.fallback_tier_id);
@ -412,27 +433,30 @@ export const customTierWireFields = (
tiers: Object.fromEntries(rows.map((row) => [activeTierName(row), row.models])),
tier_definitions: tierDefinitionsFromRows(rows),
...(fallback && { fallback_tier: activeTierName(fallback) }),
classifier_type: "llm",
classifier_type: classifierType === "jev" ? "jev" : "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.
...(classifierLlmConfig && {
classifier_llm_config: {
model: classifierLlmConfig.model,
timeout_ms: classifierLlmConfig.timeout_ms,
...(classifierLlmConfig.circuit_breaker_enabled !== undefined && {
circuit_breaker_enabled: classifierLlmConfig.circuit_breaker_enabled,
}),
...(classifierLlmConfig.circuit_breaker_cooldown_seconds !== undefined && {
circuit_breaker_cooldown_seconds: classifierLlmConfig.circuit_breaker_cooldown_seconds,
}),
...(classifierLlmConfig.reasoning_effort && { reasoning_effort: classifierLlmConfig.reasoning_effort }),
...(classifierLlmConfig.vision && { vision: classifierLlmConfig.vision }),
},
}),
...(classifierType !== "jev" &&
classifierLlmConfig && {
classifier_llm_config: {
model: classifierLlmConfig.model,
timeout_ms: classifierLlmConfig.timeout_ms,
...(classifierLlmConfig.circuit_breaker_enabled !== undefined && {
circuit_breaker_enabled: classifierLlmConfig.circuit_breaker_enabled,
}),
...(classifierLlmConfig.circuit_breaker_cooldown_seconds !== undefined && {
circuit_breaker_cooldown_seconds: classifierLlmConfig.circuit_breaker_cooldown_seconds,
}),
...(classifierLlmConfig.reasoning_effort && { reasoning_effort: classifierLlmConfig.reasoning_effort }),
...(classifierLlmConfig.vision && { vision: classifierLlmConfig.vision }),
},
}),
session_affinity: false,
...(classificationPrompt?.trim() && { classification_prompt: classificationPrompt.trim() }),
...(classificationExamples?.trim() && { classification_examples: classificationExamples.trim() }),
...(classifierType !== "jev" &&
classificationPrompt?.trim() && { classification_prompt: classificationPrompt.trim() }),
...(classifierType !== "jev" &&
classificationExamples?.trim() && { classification_examples: classificationExamples.trim() }),
...(floor && { plan_mode_min_tier: activeTierName(floor) }),
};
};
@ -521,7 +545,7 @@ const classifierWireFields = (
| "classifierContextIncludeAssistantTurns"
>,
): Partial<ComplexityRouterConfigPayload> => {
const supportsFallback = usesLlmClassifier(effectiveType) && !isForecastClassifier(effectiveType);
const supportsFallback = usesClassifierContext(effectiveType) && !isForecastClassifier(effectiveType);
return {
...(usesLlmClassifier(effectiveType) &&
classifierLlmConfig && {
@ -534,15 +558,15 @@ const classifierWireFields = (
heuristicFirstMaxTier?.trim() && { heuristic_first_max_tier: heuristicFirstMaxTier }),
...(effectiveType === "hybrid" &&
hybridBoundaryMargin !== undefined && { hybrid_boundary_margin: hybridBoundaryMargin }),
...(usesLlmClassifier(effectiveType) &&
...(usesClassifierContext(effectiveType) &&
classifierContextWindowSize !== undefined && {
classifier_context_window_size: classifierContextWindowSize,
}),
...(usesLlmClassifier(effectiveType) &&
...(usesClassifierContext(effectiveType) &&
classifierContextBudgetChars !== undefined && {
classifier_context_budget_chars: classifierContextBudgetChars,
}),
...(usesLlmClassifier(effectiveType) &&
...(usesClassifierContext(effectiveType) &&
classifierContextIncludeAssistantTurns !== undefined && {
classifier_context_include_assistant_turns: classifierContextIncludeAssistantTurns,
}),
@ -560,6 +584,7 @@ export const buildComplexityRouterConfig = ({
capabilityClassifierConfig,
llmV2Config,
classifierLlmConfig,
jevClassifierConfig,
classifierContextWindowSize,
classifierContextBudgetChars,
classifierContextIncludeAssistantTurns,
@ -625,9 +650,7 @@ export const buildComplexityRouterConfig = ({
classifierContextBudgetChars,
classifierContextIncludeAssistantTurns,
};
// An edited tier set forces the LLM classifier, so llm-only inputs must survive a classifier_type
// the form never rewrote. The UI gates the same controls on this, not on the raw value.
const effectiveType: ClassifierType = customTierSet ? "llm" : classifierType;
const effectiveType = effectiveClassifierType({ custom_tier_set: customTierSet, classifier_type: classifierType });
const forecast = isForecastClassifier(effectiveType);
const supportsOpeningPrompt = !customTierSet && !forecast && usesLlmClassifier(effectiveType);
@ -640,6 +663,7 @@ export const buildComplexityRouterConfig = ({
...(planModeMinTier?.trim() && { plan_mode_min_tier: planModeMinTier }),
...(cleanedTierLabels && { tier_labels: cleanedTierLabels }),
classifier_type: classifierType,
...(effectiveType === "jev" && { jev_classifier_config: normalizeJevClassifierConfig(jevClassifierConfig) }),
...classifierWireFields(effectiveType, classifierInputs),
...(effectiveType === "capability" &&
capabilityClassifierConfig && { capability_classifier_config: capabilityClassifierConfig }),
@ -700,6 +724,7 @@ export const buildComplexityRouterConfig = ({
Object.entries(payload).filter(([key]) => !CUSTOM_TIER_STRIPPED_KEYS.includes(key)),
) as ComplexityRouterConfigPayload;
const customTierInputs: CustomTierWireFieldInputs = {
classifierType: effectiveType,
classifierLlmConfig,
planModeMinTierId: planModeMinTier,
classificationPrompt,

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
import { effectiveClassifierType, type ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
import { transitionClassifierType } from "./classifier_type_transition";
import { applyTierSetAction } from "./tier_set_actions";
const standard: ComplexityRouterConfigValue = {
classifier_type: "llm",
@ -13,6 +14,44 @@ const standard: ComplexityRouterConfigValue = {
};
describe("transitionClassifierType", () => {
it("switches between LLM and JEV without losing shared routing settings or leaking opposite config", () => {
const initial = {
...standard,
classification_prompt: "LLM only",
classification_examples: "LLM examples",
enable_non_reasoning_tier: true,
tiers: { ...standard.tiers, NON_REASONING: ["fast"] },
plan_mode_min_tier: "NON_REASONING",
adaptive: true,
};
const jev = transitionClassifierType(initial, "jev");
expect(jev).toMatchObject({
classifier_type: "jev",
jev_classifier_config: { model: "jev-latest", timeout_ms: 3000 },
classifier_context_window_size: 8,
classifier_context_budget_chars: 16000,
classifier_context_include_assistant_turns: true,
classifier_fallback: "default_model",
adaptive: true,
enable_non_reasoning_tier: true,
plan_mode_min_tier: "NON_REASONING",
tiers: initial.tiers,
});
expect(jev.classifier_llm_config).toBeUndefined();
expect(jev.classification_prompt).toBeUndefined();
expect(jev.classification_examples).toBeUndefined();
const custom = applyTierSetAction(jev, [], { kind: "patch", id: "SIMPLE", patch: { name: "QUICK" } }).value;
expect(effectiveClassifierType(custom)).toBe("jev");
const restored = applyTierSetAction(custom, [], { kind: "restore" }).value;
expect(effectiveClassifierType(restored)).toBe("jev");
expect(restored.jev_classifier_config).toEqual(jev.jev_classifier_config);
const llm = transitionClassifierType(custom, "llm");
expect(llm.jev_classifier_config).toBeUndefined();
expect(llm.classifier_llm_config).toMatchObject({ model: "" });
expect(llm.custom_tier_set).toEqual(custom.custom_tier_set);
expect(llm.classifier_context_window_size).toBe(8);
});
it.each(["heuristic_first", "hybrid"] as const)("keeps existing LLM settings when switching to %s", (target) => {
const result = transitionClassifierType(standard, target);
const expectedSettings = {

View file

@ -8,7 +8,9 @@ import {
DEFAULT_HYBRID_BOUNDARY_MARGIN,
NEW_CLASSIFIER_CLASSIFICATION_RUBRIC,
usesLlmClassifier,
usesClassifierContext,
} from "./ComplexityRouterConfig";
import { defaultJevClassifierConfig } from "./jev_classifier_config";
import { isForecastClassifier, prepareForecastClassifier } from "./forecast_classifier_config";
import { nonReasoningTierFields } from "./nonReasoningTierFields";
@ -22,22 +24,29 @@ export const transitionClassifierType = (
const judgeConfig = value.classifier_llm_config ?? { model: "", timeout_ms: DEFAULT_CLASSIFIER_TIMEOUT_MS };
const nextValue: ComplexityRouterConfigValue = {
...value,
jev_classifier_config:
classifierType === "jev" ? value.jev_classifier_config ?? defaultJevClassifierConfig() : undefined,
classification_prompt: classifierType === "jev" ? undefined : value.classification_prompt,
classification_examples: classifierType === "jev" ? undefined : value.classification_examples,
classifier_llm_config: usesLlmClassifier(classifierType)
? {
...judgeConfig,
...(startsLlmRubric && { classification_rubric: NEW_CLASSIFIER_CLASSIFICATION_RUBRIC }),
}
: undefined,
classifier_context_window_size: usesLlmClassifier(classifierType)
classifier_context_window_size: usesClassifierContext(classifierType)
? value.classifier_context_window_size ?? DEFAULT_CLASSIFIER_CONTEXT_WINDOW_SIZE
: undefined,
classifier_context_budget_chars: usesLlmClassifier(classifierType)
classifier_context_budget_chars: usesClassifierContext(classifierType)
? value.classifier_context_budget_chars ?? DEFAULT_CLASSIFIER_CONTEXT_BUDGET_CHARS
: undefined,
classifier_context_include_assistant_turns: usesLlmClassifier(classifierType)
classifier_context_per_turn_chars: usesClassifierContext(classifierType)
? value.classifier_context_per_turn_chars
: undefined,
classifier_context_include_assistant_turns: usesClassifierContext(classifierType)
? value.classifier_context_include_assistant_turns
: undefined,
classifier_fallback: usesLlmClassifier(classifierType) ? value.classifier_fallback : undefined,
classifier_fallback: usesClassifierContext(classifierType) ? value.classifier_fallback : undefined,
heuristic_first_max_tier:
classifierType === "heuristic_first"
? value.heuristic_first_max_tier ?? DEFAULT_HEURISTIC_FIRST_MAX_TIER

View file

@ -0,0 +1,15 @@
export type ClassifierType =
| "heuristic"
| "heuristic_v2"
| "llm"
| "jev"
| "heuristic_first"
| "hybrid"
| "capability"
| "llm_v2";
export const usesLlmClassifier = (classifierType: ClassifierType): boolean =>
(["llm", "heuristic_first", "hybrid", "capability", "llm_v2"] as const).some((type) => type === classifierType);
export const usesClassifierContext = (classifierType: ClassifierType): boolean =>
classifierType === "jev" || usesLlmClassifier(classifierType);

View file

@ -0,0 +1,28 @@
import { z } from "zod";
export const jevClassifierConfigSchema = z.object({
model: z.string().trim().min(1).default("jev-latest"),
timeout_ms: z.number().int().positive().default(3000),
instructions: z
.string()
.nullish()
.transform((value) => value ?? undefined),
circuit_breaker_enabled: z.boolean().optional(),
circuit_breaker_cooldown_seconds: z.number().finite().positive().optional(),
});
export type JevClassifierConfig = z.infer<typeof jevClassifierConfigSchema>;
export const defaultJevClassifierConfig = (): JevClassifierConfig => jevClassifierConfigSchema.parse({});
export const normalizeJevClassifierConfig = (
config: JevClassifierConfig = defaultJevClassifierConfig(),
): JevClassifierConfig => ({
model: config.model.trim(),
timeout_ms: config.timeout_ms,
...(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 && {
circuit_breaker_cooldown_seconds: config.circuit_breaker_cooldown_seconds,
}),
});

View file

@ -12,7 +12,7 @@ export const nonReasoningTierFields = (
classifierType: ClassifierType,
value: ComplexityRouterConfigValue,
): Pick<ComplexityRouterConfigValue, "enable_non_reasoning_tier" | "tiers" | "plan_mode_min_tier"> => {
if (classifierType === "llm") {
if (classifierType === "llm" || classifierType === "jev") {
return {
enable_non_reasoning_tier: value.enable_non_reasoning_tier,
tiers: value.tiers,

View file

@ -145,7 +145,7 @@ export const CUSTOM_TIER_RESTRICTIONS = {
heuristicClassifier: {
omit: ["heuristic_first_max_tier", "hybrid_boundary_margin"],
reason:
"The heuristic scorer only produces the built-in tiers, so an edited set needs the LLM classifier. " +
"The heuristic scorer only produces the built-in tiers, so an edited set needs the LLM or JEV classifier. " +
"Heuristic first and hybrid are out for the same reason: their local scorer decides the traffic it is sure of",
},
heuristicScoring: {

View file

@ -1,4 +1,6 @@
import { describe, expect, it } from "vitest";
import { transitionClassifierType } from "../add_model/classifier_type_transition";
import { effectiveClassifierType } from "../add_model/ComplexityRouterConfig";
import {
MANAGED_COMPLEXITY_ROUTER_KEYS,
@ -46,6 +48,62 @@ const hydratedState: KeywordMatchingState = {
};
describe("buildUpdatedComplexityRouterConfig keyword matching", () => {
it("hydrates nullable JEV instructions without resetting the server configuration", () => {
const stored = {
classifier_type: "jev" as const,
jev_classifier_config: {
model: "jev-configured",
timeout_ms: 6100,
instructions: null,
circuit_breaker_enabled: false,
},
tiers: FORM_VALUE.tiers,
};
const saved = buildUpdatedComplexityRouterConfig(stored, hydrateComplexityRouterConfig(stored, undefined));
expect(saved.jev_classifier_config).toEqual({
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) => {
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",
timeout_ms: 4100,
instructions: "Judge the request",
circuit_breaker_enabled: false,
circuit_breaker_cooldown_seconds: 10.5,
},
classifier_context_window_size: 7,
classifier_context_budget_chars: 9000,
classifier_context_include_assistant_turns: true,
some_future_backend_key: { nested: true },
};
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);
const saved = buildUpdatedComplexityRouterConfig(stored, hydrated);
expect(saved).toMatchObject({
classifier_type: "jev",
jev_classifier_config: stored.jev_classifier_config,
classifier_context_window_size: 7,
classifier_context_budget_chars: 9000,
classifier_context_include_assistant_turns: true,
some_future_backend_key: { nested: true },
});
expect(saved).not.toHaveProperty("classifier_llm_config");
const reloaded = hydrateComplexityRouterConfig(saved, undefined);
expect(reloaded.jev_classifier_config).toEqual(hydrated.jev_classifier_config);
expect(effectiveClassifierType(reloaded)).toBe("jev");
const llm = buildUpdatedComplexityRouterConfig(saved, transitionClassifierType(reloaded, "llm"));
expect(llm).not.toHaveProperty("jev_classifier_config");
});
it.each(["capability", "llm_v2", "heuristic"] as const)(
"handles enabled stored overrides when editing %s with or without keyword form state",
(classifier_type) => {
@ -700,7 +758,12 @@ describe("managed keys survive an untouched open-and-save", () => {
// tier_definitions and fallback_tier cannot sit beside heuristic_first, which this fixture uses,
// and hybrid_boundary_margin belongs to the sibling hybrid type, so no single stored config can
// hold every managed key. Each gets its own round trip below.
const KEYS_ANOTHER_CLASSIFIER_TYPE_OWNS = new Set(["tier_definitions", "fallback_tier", "hybrid_boundary_margin"]);
const KEYS_ANOTHER_CLASSIFIER_TYPE_OWNS = new Set([
"tier_definitions",
"fallback_tier",
"hybrid_boundary_margin",
"jev_classifier_config",
]);
// The stall keys are rejected beside the session pinning and user-turn classification this
// fixture sets, so they get their own round trip below rather than widening this one.

View file

@ -1,4 +1,5 @@
import AutoRouterClassifierTabs from "../add_model/AutoRouterClassifierTabs";
import { defaultJevClassifierConfig, jevClassifierConfigSchema } from "../add_model/jev_classifier_config";
import type { StoredComplexityRouterConfig } from "../add_model/build_complexity_router_config";
export type { StoredComplexityRouterConfig } from "../add_model/build_complexity_router_config";
import {
@ -129,7 +130,12 @@ export const hydrateComplexityRouterConfig = (
classifier_type: parsedConfig.classifier_type || "heuristic",
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_llm_config,
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_context_window_size:
typeof parsedConfig.classifier_context_window_size === "number"
? parsedConfig.classifier_context_window_size
@ -219,6 +225,7 @@ export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([
"capability_classifier_config",
"llm_v2_config",
"classifier_llm_config",
"jev_classifier_config",
"classifier_context_window_size",
"classifier_context_budget_chars",
"classifier_context_include_assistant_turns",
@ -329,6 +336,7 @@ export const buildUpdatedComplexityRouterConfig = (
classificationMode: value.classification_mode,
tierLabels: value.tier_labels,
classifierType: value.classifier_type,
jevClassifierConfig: value.jev_classifier_config,
capabilityClassifierConfig: value.capability_classifier_config,
llmV2Config: value.llm_v2_config,
classifierLlmConfig: value.classifier_llm_config,

View file

@ -15,6 +15,7 @@ import { copyToClipboard as utilCopyToClipboard } from "../utils/dataUtils";
import { stripMaskedSecrets } from "../utils/maskedSecretUtils";
import { truncateString } from "../utils/textUtils";
import AutoRouterConnectionTest from "./add_model/auto_router_connection_test";
import { buildSavedJevConnectionTestRequest } from "./add_model/build_auto_router_routing_test_request";
import { AutoRouterTestTarget, buildComplexityRouterTestTargets } from "./add_model/build_auto_router_test_targets";
import {
hasAutoRouterEditor,
@ -846,6 +847,12 @@ export default function ModelInfoView({
key={autoRouterTestId}
accessToken={accessToken}
targets={autoRouterTestTargets}
jevRequest={buildSavedJevConnectionTestRequest(
(localModelData ?? modelData)?.litellm_params?.complexity_router_config,
(localModelData ?? modelData)?.litellm_params?.complexity_router_default_model,
(localModelData ?? modelData)?.model_name,
(localModelData ?? modelData)?.model_info?.team_id,
)}
/>
)}
<DialogFooter>

View file

@ -2326,7 +2326,7 @@ export const testModelGroupConnection = async (
export interface AutoRouterRoutingTestRequest {
prompt: string;
complexity_router_config: ComplexityRouterConfigPayload;
complexity_router_config: ComplexityRouterConfigPayload | Record<string, unknown>;
default_model?: string;
router_name?: string;
team_id?: string;

View file

@ -103,7 +103,7 @@ describe("RoutingDecisionCard", () => {
}}
/>,
);
expect(screen.getByText("Default model, LLM classifier failed")).toBeInTheDocument();
expect(screen.getByText("Default model, classifier failed")).toBeInTheDocument();
expect(screen.queryByText("Tier")).not.toBeInTheDocument();
});
@ -120,7 +120,7 @@ describe("RoutingDecisionCard", () => {
}}
/>,
);
expect(screen.getByText("Fallback tier, LLM classifier failed")).toBeInTheDocument();
expect(screen.getByText("Fallback tier, classifier failed")).toBeInTheDocument();
expect(screen.getByText("SECURITY_REVIEW")).toBeInTheDocument();
});

View file

@ -24,6 +24,9 @@ export interface RoutingDecision {
matched_keyword?: string;
escalation_keyword?: string;
classifier_model?: string;
classifier_confidence?: number;
classifier_probabilities?: Record<string, number>;
classifier_cost?: number;
escalated?: boolean;
tier_boundaries?: RoutingDecisionTierBoundaries;
reasoning_override_min_score?: number;
@ -97,8 +100,8 @@ const CONSTANT_CAUSE_LABELS: Record<string, string> = {
quality_tier: "Quality tier mapping",
bandit: "Adaptive bandit",
default_fallback: "Default model, no route matched",
classifier_fallback: "Fallback tier, LLM classifier failed",
default_model_fallback: "Default model, LLM classifier failed",
classifier_fallback: "Fallback tier, classifier failed",
default_model_fallback: "Default model, classifier failed",
};
function describeCause(decision: RoutingDecision): string {
@ -118,6 +121,8 @@ function describeCause(decision: RoutingDecision): string {
return describeReasoningOverride(tierLabel, overrideFloor);
case "llm_classifier":
return classifierModel ? `LLM classifier (${classifierModel})` : "LLM classifier";
case "jev_classifier":
return "JEV classifier";
case "literal_keyword_match":
case "keyword":
return matchedKeyword ? `Keyword match: "${matchedKeyword}"` : "Keyword match";
@ -208,6 +213,20 @@ export function RoutingDecisionCard({
{requestType && <Row label="Request type">{requestType}</Row>}
<Row label="Decided by">{describeCause(decision)}</Row>
{decision.classifier_model && <Row label="Classifier model">{decision.classifier_model}</Row>}
{decision.classifier_confidence != null && (
<Row label="Confidence">{(decision.classifier_confidence * 100).toFixed(1)}%</Row>
)}
{decision.classifier_probabilities && (
<Row label="Probabilities">
{Object.entries(decision.classifier_probabilities).map(([name, probability]) => (
<div key={name}>
{name}: {(probability * 100).toFixed(1)}%
</div>
))}
</Row>
)}
{decision.classifier_cost != null && <Row label="Classifier cost">${decision.classifier_cost.toFixed(8)}</Row>}
{score !== undefined && (
<Row label="Score">

View file

@ -680,6 +680,32 @@ describe("autorouter_presets", () => {
});
describe("buildPresetPrefill", () => {
it("preserves JEV settings and drops inactive classifier settings when prefilling", () => {
const config = {
tiers: { SIMPLE: ["fast"], MEDIUM: [], COMPLEX: [], REASONING: [] },
classifier_type: "jev" 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 },
classifier_llm_config: { model: "stale-judge", timeout_ms: 6000 },
classifier_context_window_size: 6,
};
const prefill = buildPresetPrefill(config, groupsOnly(["fast"]));
expect(prefill.complexityRouterConfig).toMatchObject({
classifier_type: "jev",
jev_classifier_config: config.jev_classifier_config,
classifier_context_window_size: 6,
classifier_llm_config: undefined,
});
const llmConfig = { ...config, classifier_type: "llm" as const };
const llmPrefill = buildPresetPrefill(llmConfig, groupsOnly(["fast"]));
expect(llmPrefill.complexityRouterConfig.jev_classifier_config).toBeUndefined();
expect(llmPrefill.complexityRouterConfig.classifier_llm_config).toEqual(config.classifier_llm_config);
});
it("prefills a real bundled preset's tiers into the config", () => {
const preset = getPresetByKey("anthropic_family")!;
const prefill = buildPresetPrefill(

View file

@ -284,10 +284,11 @@ 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_llm_config: config.classifier_llm_config && {
...config.classifier_llm_config,
model: resolve(config.classifier_llm_config.model),
},
jev_classifier_config: config.classifier_type === "jev" ? config.jev_classifier_config : undefined,
classifier_llm_config:
config.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,
classifier_context_budget_chars: config.classifier_context_budget_chars,
classifier_context_per_turn_chars: config.classifier_context_per_turn_chars,