mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
Merge eb3c616e2b into f8f05767da
This commit is contained in:
commit
1e608b1536
19 changed files with 344 additions and 44 deletions
|
|
@ -85,7 +85,7 @@ describe("autoRouterRows", () => {
|
|||
|
||||
it.each([
|
||||
["llm", "LLM Classifier"],
|
||||
["jev", "JEV Classifier"],
|
||||
["jev", "Decision Model"],
|
||||
])("labels a router using the %s classifier", (classifierType, label) => {
|
||||
const row = toAutoRouterRow(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ const dedupe = (models: string[]): string[] => Array.from(new Set(models));
|
|||
|
||||
const COMPLEXITY_TYPE_LABELS: Record<string, string> = {
|
||||
llm: "LLM Classifier",
|
||||
jev: "JEV Classifier",
|
||||
jev: "Decision Model",
|
||||
capability: "Capability",
|
||||
llm_v2: "Fuse v2",
|
||||
heuristic_first: "Heuristic first",
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ describe("Auto-router classifier selection", () => {
|
|||
llm: "LLM",
|
||||
heuristic_first: "LLM",
|
||||
hybrid: "LLM",
|
||||
jev: "Jev",
|
||||
jev: "Decision Model",
|
||||
}[classifier_type];
|
||||
expect(screen.getByRole("radio", { name: new RegExp(`^${family}$`) })).toBeChecked();
|
||||
fireEvent.click(screen.getByRole("radio", { name: new RegExp(`^${family}$`) }));
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({ val
|
|||
{[
|
||||
{ value: "heuristics", label: "Heuristics", description: "Classify locally, with no API call" },
|
||||
{ value: "llm", label: "LLM", description: "Use a judge model to choose a solver" },
|
||||
{ value: "jev", label: "Jev", description: "Use TypeSafe System One Choice to choose a tier" },
|
||||
{ value: "jev", label: "Decision Model", description: "Use Jev or open-source Nimble to choose a tier" },
|
||||
].map((option) => (
|
||||
<Label
|
||||
key={option.value}
|
||||
|
|
|
|||
|
|
@ -642,8 +642,8 @@ const ClassificationMethodConfig: React.FC<ClassificationMethodConfigProps> = ({
|
|||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Number of prior user turns sent to the classifier provider, excluding tool output and harness reminders.
|
||||
LLM and Jev default to 3 turns; Jev sends them to the configured TypeSafe endpoint. Set to 0 to omit
|
||||
conversation history. The current message and selected system text are still sent.
|
||||
LLM and decision models default to 3 turns and send them to the configured classifier endpoint. Set to 0
|
||||
to omit conversation history. The current message and selected system text are still sent.
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -54,8 +54,8 @@ const ClassifierTypeRadios: React.FC<ClassifierTypeRadiosProps> = ({ value, clas
|
|||
<Label className="items-start font-normal leading-normal">
|
||||
<RadioGroupItem value="jev" className="mt-0.5" />
|
||||
<span>
|
||||
<strong className="font-semibold">Jev Classifier</strong>{" "}
|
||||
<span className="text-muted-foreground">uses TypeSafe System One Choice to decide the tier</span>
|
||||
<strong className="font-semibold">Decision Model</strong>{" "}
|
||||
<span className="text-muted-foreground">uses Jev or open-source Nimble to decide the tier</span>
|
||||
</span>
|
||||
</Label>
|
||||
<SimpleTooltip content={scorerLockedReason}>
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@ const TierSetToolbar: React.FC<{
|
|||
{editing && (
|
||||
<span className="block mt-1 text-xs text-muted-foreground">
|
||||
Add or remove tiers to define your own set. Every custom tier needs a definition the classifier routes on, and
|
||||
an edited set requires the LLM or Jev classification method
|
||||
an edited set requires the LLM or Decision Model classification method
|
||||
</span>
|
||||
)}
|
||||
{editing && keywordRulesError && (
|
||||
|
|
|
|||
|
|
@ -96,14 +96,87 @@ function Form() {
|
|||
|
||||
describe("JEV classifier editor", () => {
|
||||
afterEach(() => vi.mocked(useAuthorized).mockReset());
|
||||
it("creates Nimble connection settings and retains the provider after save and reload", () => {
|
||||
renderWithProviders(<Form />);
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Decision Model" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Nimble (open source)" }));
|
||||
expect(screen.getByLabelText("Nimble Model")).toHaveValue("nimble-latest");
|
||||
fireEvent.change(screen.getByLabelText("API Base"), { target: { value: "https://nimble.example.com" } });
|
||||
fireEvent.change(screen.getByLabelText("API Key"), { target: { value: "new-nimble-key" } });
|
||||
fireEvent.change(screen.getByLabelText("Nimble Timeout (ms)"), { target: { value: "20000" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Probe current config" }));
|
||||
expect(testAutoRouterRouting).toHaveBeenLastCalledWith(
|
||||
"token",
|
||||
expect.objectContaining({
|
||||
complexity_router_config: expect.objectContaining({
|
||||
classifier_type: "jev",
|
||||
jev_classifier_config: {
|
||||
provider: "bespoke_nimble",
|
||||
model: "nimble-latest",
|
||||
timeout_ms: 20000,
|
||||
api_base: "https://nimble.example.com",
|
||||
api_key: "new-nimble-key",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and reload" }));
|
||||
expect(screen.getByRole("radio", { name: "Nimble (open source)" })).toBeChecked();
|
||||
expect(screen.getByLabelText("Nimble Timeout (ms)")).toHaveValue(20000);
|
||||
expect(screen.getByLabelText("API Key")).toHaveValue("");
|
||||
fireEvent.click(screen.getByText("Connection settings"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear saved API key" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Probe current config" }));
|
||||
expect(testAutoRouterRouting).toHaveBeenLastCalledWith(
|
||||
"token",
|
||||
expect.objectContaining({
|
||||
complexity_router_config: expect.objectContaining({
|
||||
jev_classifier_config: {
|
||||
provider: "bespoke_nimble",
|
||||
model: "nimble-latest",
|
||||
timeout_ms: 20000,
|
||||
api_key: null,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use gateway connection" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Probe current config" }));
|
||||
expect(testAutoRouterRouting).toHaveBeenLastCalledWith(
|
||||
"token",
|
||||
expect.objectContaining({
|
||||
complexity_router_config: expect.objectContaining({
|
||||
jev_classifier_config: {
|
||||
provider: "bespoke_nimble",
|
||||
model: "nimble-latest",
|
||||
timeout_ms: 20000,
|
||||
api_key: null,
|
||||
api_base: null,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["Internal User", "Admin Viewer", "org_admin"])("hides connection overrides for %s", (userRole) => {
|
||||
vi.mocked(useAuthorized).mockReturnValue({ ...useAuthorized(), userRole });
|
||||
renderWithProviders(<Form />);
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Decision Model" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Nimble (open source)" }));
|
||||
expect(screen.getByLabelText("Nimble Model")).toHaveValue("nimble-latest");
|
||||
expect(screen.queryByText("Connection settings")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("API Key")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("API Base")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses built-in JEV without a license and preserves custom tiers and context through reload", () => {
|
||||
renderWithProviders(<Form />);
|
||||
expect(screen.getByLabelText("Judge model")).toBeInTheDocument();
|
||||
expect(screen.getByText("Reasoning Effort")).toBeInTheDocument();
|
||||
expect(screen.getByText("Classifier Prompt")).toBeInTheDocument();
|
||||
expect(screen.getByRole("switch", { name: "Use images for classification" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("radio", { name: /Jev Classifier/ }));
|
||||
expect(screen.getByRole("radio", { name: /^Jev Classifier/ })).toBeChecked();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Decision Model" }));
|
||||
expect(screen.getByRole("radio", { name: "Decision Model" })).toBeChecked();
|
||||
expect(screen.getByLabelText("Jev Model")).toHaveValue("jev-latest");
|
||||
expect(screen.getByLabelText("Jev Instructions")).toBeEnabled();
|
||||
expect(screen.queryByLabelText("Judge model")).not.toBeInTheDocument();
|
||||
|
|
@ -117,7 +190,7 @@ describe("JEV classifier editor", () => {
|
|||
fireEvent.click(screen.getByRole("switch", { name: "Classifier circuit breaker" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Customize tiers" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save and reload" }));
|
||||
expect(screen.getByRole("radio", { name: /Jev Classifier/ })).toBeChecked();
|
||||
expect(screen.getByRole("radio", { name: "Decision Model" })).toBeChecked();
|
||||
expect(screen.getByLabelText("Jev Model")).toHaveValue("jev-test");
|
||||
expect(screen.getByLabelText("Jev Timeout (ms)")).toHaveValue(4200);
|
||||
expect(screen.getByLabelText("Context Window Size")).toHaveValue("6");
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import { Label } from "@/components/ui/label";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import ClassifierCircuitBreakerConfig from "./ClassifierCircuitBreakerConfig";
|
||||
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
|
||||
import { defaultJevClassifierConfig } from "./jev_classifier_config";
|
||||
import { defaultJevClassifierConfig, transitionDecisionModelProvider } from "./jev_classifier_config";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { isProxyAdminRole } from "@/utils/roles";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
|
||||
export default function JevClassifierConfig({
|
||||
value,
|
||||
|
|
@ -16,21 +19,45 @@ export default function JevClassifierConfig({
|
|||
onChange: (value: ComplexityRouterConfigValue) => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const { userRole, isViewOnly } = useAuthorized();
|
||||
const config = value.jev_classifier_config ?? defaultJevClassifierConfig();
|
||||
const provider = config.provider ?? "typesafe";
|
||||
const providerLabel = provider === "bespoke_nimble" ? "Nimble" : "Jev";
|
||||
const update = (patch: Partial<typeof config>) =>
|
||||
onChange({ ...value, jev_classifier_config: { ...config, ...patch } });
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-3">
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">Decision model provider</legend>
|
||||
<RadioGroup
|
||||
value={provider}
|
||||
onValueChange={(next) => {
|
||||
if (next === "typesafe" || next === "bespoke_nimble") {
|
||||
onChange({ ...value, jev_classifier_config: transitionDecisionModelProvider(config, next) });
|
||||
}
|
||||
}}
|
||||
className="flex flex-wrap gap-4"
|
||||
>
|
||||
<Label>
|
||||
<RadioGroupItem value="typesafe" />
|
||||
TypeSafe Jev
|
||||
</Label>
|
||||
<Label>
|
||||
<RadioGroupItem value="bespoke_nimble" />
|
||||
Nimble (open source)
|
||||
</Label>
|
||||
</RadioGroup>
|
||||
</fieldset>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Uses TypeSafe System One Choice evaluation with your configured tiers
|
||||
{providerLabel} selects a tier for each request using your configured tier criteria
|
||||
</p>
|
||||
<div>
|
||||
<Label htmlFor={`${id}-model`}>Jev Model</Label>
|
||||
<Label htmlFor={`${id}-model`}>{providerLabel} Model</Label>
|
||||
<Input id={`${id}-model`} value={config.model} onChange={(event) => update({ model: event.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={`${id}-timeout`}>Jev Timeout (ms)</Label>
|
||||
<Label htmlFor={`${id}-timeout`}>{providerLabel} Timeout (ms)</Label>
|
||||
<Input
|
||||
id={`${id}-timeout`}
|
||||
type="number"
|
||||
|
|
@ -40,6 +67,58 @@ export default function JevClassifierConfig({
|
|||
onChange={(event) => update({ timeout_ms: Number(event.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
{isProxyAdminRole(userRole ?? "") && !isViewOnly && (
|
||||
<details className="space-y-3 rounded-lg border p-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">Connection settings</summary>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Saved connection values are hidden. Untouched fields keep the saved connection. New routers use the gateway
|
||||
connection when these fields are blank
|
||||
</p>
|
||||
<div>
|
||||
<Label htmlFor={`${id}-api-base`}>API Base</Label>
|
||||
<Input
|
||||
id={`${id}-api-base`}
|
||||
value={config.api_base ?? ""}
|
||||
placeholder={provider === "bespoke_nimble" ? "https://nimble.example.com" : "https://api.typesafe.ai"}
|
||||
onChange={(event) => update({ api_base: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={`${id}-api-key`}>API Key</Label>
|
||||
<Input
|
||||
id={`${id}-api-key`}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={config.api_key ?? ""}
|
||||
placeholder={
|
||||
provider === "bespoke_nimble" ? "Optional for a keyless Nimble server" : "Enter the endpoint's key"
|
||||
}
|
||||
onChange={(event) => update({ api_key: event.target.value || null })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" type="button" onClick={() => update({ api_base: null, api_key: null })}>
|
||||
Use gateway connection
|
||||
</Button>
|
||||
{provider === "bespoke_nimble" && (
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => update({ api_key: null })}>
|
||||
Clear saved API key
|
||||
</Button>
|
||||
<p className="w-full text-xs text-muted-foreground">
|
||||
Clearing the key keeps a saved custom endpoint and connects without authentication. A gateway
|
||||
connection still uses its configured key. Use gateway connection clears both router overrides
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{config.api_base === null && config.api_key === null && (
|
||||
<p role="status" className="text-xs text-muted-foreground">
|
||||
Gateway connection selected for the next save
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
<ClassifierCircuitBreakerConfig
|
||||
value={config}
|
||||
onChange={(next) =>
|
||||
|
|
@ -50,7 +129,7 @@ export default function JevClassifierConfig({
|
|||
}
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor={`${id}-instructions`}>Jev Instructions</Label>
|
||||
<Label htmlFor={`${id}-instructions`}>{providerLabel} Instructions</Label>
|
||||
<AutoRouterAllowanceNote
|
||||
feature="tier_or_classifier_prompt"
|
||||
label="Custom instructions share the custom-tier allowance"
|
||||
|
|
@ -63,11 +142,11 @@ export default function JevClassifierConfig({
|
|||
/>
|
||||
{config.instructions && (
|
||||
<Button variant="outline" type="button" onClick={() => update({ instructions: undefined })}>
|
||||
Restore built-in Jev instructions
|
||||
Restore built-in {providerLabel} instructions
|
||||
</Button>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Built-in Jev is available without a license and uses the shipped tier criteria
|
||||
Built-in decision model classification is available without a license and uses the shipped tier criteria
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -73,6 +73,41 @@ const response = (cause: string) => ({
|
|||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("JEV network probes", () => {
|
||||
it.each(["jev_classifier", "classifier_fallback"])("labels Nimble probe results for %s", async (cause) => {
|
||||
const fetchMock = vi.fn<typeof fetch>(async () => new Response(JSON.stringify(response(cause))));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const nimbleRequest = buildSavedJevConnectionTestRequest(
|
||||
{
|
||||
...config,
|
||||
jev_classifier_config: {
|
||||
provider: "bespoke_nimble",
|
||||
model: "nimble-latest",
|
||||
timeout_ms: 20000,
|
||||
api_key: "masked-secret",
|
||||
api_base: "https://nimble.example.com",
|
||||
},
|
||||
},
|
||||
"nimble-router",
|
||||
);
|
||||
renderWithProviders(<AutoRouterConnectionTest accessToken="token" targets={[]} jevRequest={nimbleRequest} />);
|
||||
expect(
|
||||
await screen.findByText(
|
||||
cause === "jev_classifier"
|
||||
? "Nimble classification succeeded"
|
||||
: "Nimble was not reached successfully (routing cause: classifier_fallback)",
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole("status", { name: "Nimble connection" })).toHaveTextContent("Nimble Classifier");
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[0][1]?.body))).toEqual({
|
||||
prompt: JEV_CONNECTION_TEST_PROMPT,
|
||||
saved_model_id: "nimble-router",
|
||||
complexity_router_config: {
|
||||
...config,
|
||||
jev_classifier_config: { provider: "bespoke_nimble", model: "nimble-latest", timeout_ms: 20000 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["jev_classifier", "classifier_fallback", "default_model_fallback", "keyword_match"])(
|
||||
"probes the routing endpoint independently of tier models and checks the cause %s",
|
||||
async (cause) => {
|
||||
|
|
|
|||
|
|
@ -274,7 +274,7 @@ describe("AddAutoRouterTab", () => {
|
|||
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
|
||||
renderWithProviders(<Harness />);
|
||||
await user.click(await screen.findByRole("button", { name: "Choose models for me" }));
|
||||
await user.click(screen.getByRole("radio", { name: "Jev" }));
|
||||
await user.click(screen.getByRole("radio", { name: "Decision Model" }));
|
||||
await waitFor(() =>
|
||||
expect(apiClient.post).toHaveBeenLastCalledWith(
|
||||
"/auto_router/availability",
|
||||
|
|
@ -301,7 +301,7 @@ describe("AddAutoRouterTab", () => {
|
|||
expect(within(screen.getByRole("alert")).getByRole("link", { name: "Talk to our team" })).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "Restore defaults" }));
|
||||
await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
|
||||
expect(screen.getByRole("radio", { name: "Jev" })).toBeChecked();
|
||||
expect(screen.getByRole("radio", { name: "Decision Model" })).toBeChecked();
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeEnabled());
|
||||
await user.click(screen.getByRole("button", { name: "Add Auto Router" }));
|
||||
await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled());
|
||||
|
|
@ -317,7 +317,7 @@ describe("AddAutoRouterTab", () => {
|
|||
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
|
||||
renderWithProviders(<Harness />);
|
||||
await user.click(await screen.findByRole("button", { name: "Choose models for me" }));
|
||||
await user.click(screen.getByRole("radio", { name: "Jev" }));
|
||||
await user.click(screen.getByRole("radio", { name: "Decision Model" }));
|
||||
fireEvent.change(screen.getByLabelText("Auto Router Name"), { target: { value: "checked-router" } });
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Add Auto Router" })).toBeEnabled());
|
||||
let complete: ((result: unknown) => void) | undefined;
|
||||
|
|
@ -357,17 +357,22 @@ describe("AddAutoRouterTab", () => {
|
|||
expect(screen.getByRole("button", { name: "Routing approach" })).toHaveTextContent("Complexity");
|
||||
});
|
||||
|
||||
it.each(["LLM", "Jev"])("keeps %s and the frequency when choosing models automatically", async (family) => {
|
||||
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
|
||||
renderWithProviders(<Harness />);
|
||||
const automatic = await screen.findByRole("button", { name: "Choose models for me" });
|
||||
await userEvent.click(screen.getByRole("radio", { name: family }));
|
||||
await selectAutoRouterOption("How often to classify", "Every new user message");
|
||||
await userEvent.click(automatic);
|
||||
expect(screen.getByRole("radio", { name: family })).toBeChecked();
|
||||
expect(screen.getByRole("combobox", { name: "How often to classify" })).toHaveTextContent("Every new user message");
|
||||
expect(screen.getByRole("button", { name: "Advanced settings" })).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
it.each(["LLM", "Decision Model"])(
|
||||
"keeps %s and the frequency when choosing models automatically",
|
||||
async (family) => {
|
||||
mockFetchAvailableModels.mockResolvedValue(ALL_FAMILY_MODELS);
|
||||
renderWithProviders(<Harness />);
|
||||
const automatic = await screen.findByRole("button", { name: "Choose models for me" });
|
||||
await userEvent.click(screen.getByRole("radio", { name: family }));
|
||||
await selectAutoRouterOption("How often to classify", "Every new user message");
|
||||
await userEvent.click(automatic);
|
||||
expect(screen.getByRole("radio", { name: family })).toBeChecked();
|
||||
expect(screen.getByRole("combobox", { name: "How often to classify" })).toHaveTextContent(
|
||||
"Every new user message",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Advanced settings" })).toHaveAttribute("aria-expanded", "false");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["Capability", "Fuse v2"])(
|
||||
"creates %s from its dedicated tab without complexity templates",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { AutoRouterTestTarget } from "./build_auto_router_test_targets";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { jevClassifierConfigSchema } from "./jev_classifier_config";
|
||||
|
||||
interface AutoRouterConnectionTestProps {
|
||||
accessToken: string;
|
||||
|
|
@ -31,6 +32,9 @@ const AutoRouterConnectionTest: React.FC<AutoRouterConnectionTestProps> = ({
|
|||
jevRequest,
|
||||
onTestComplete,
|
||||
}) => {
|
||||
const provider = jevClassifierConfigSchema.safeParse(jevRequest?.complexity_router_config.jev_classifier_config).data
|
||||
?.provider;
|
||||
const classifierLabel = provider === "bespoke_nimble" ? "Nimble" : "Jev";
|
||||
const [results, setResults] = React.useState<TargetResult[]>(() => targets.map(() => ({ status: "pending" })));
|
||||
const [jevResult, setJevResult] = React.useState<TargetResult>({ status: "pending" });
|
||||
|
||||
|
|
@ -50,7 +54,7 @@ const AutoRouterConnectionTest: React.FC<AutoRouterConnectionTestProps> = ({
|
|||
? { status: "success" }
|
||||
: {
|
||||
status: "error",
|
||||
error: `Jev was not reached successfully (routing cause: ${decision.cause ?? "unknown"})`,
|
||||
error: `${classifierLabel} was not reached successfully (routing cause: ${decision.cause ?? "unknown"})`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
@ -91,11 +95,11 @@ const AutoRouterConnectionTest: React.FC<AutoRouterConnectionTestProps> = ({
|
|||
classifier probe includes its reasoning effort override.
|
||||
</p>
|
||||
{jevRequest && (
|
||||
<div role="status" aria-label="Jev connection" className="rounded-lg border p-3 text-sm">
|
||||
<strong>Jev Classifier</strong>
|
||||
<div role="status" aria-label={`${classifierLabel} connection`} className="rounded-lg border p-3 text-sm">
|
||||
<strong>{classifierLabel} Classifier</strong>
|
||||
<p>
|
||||
{jevResult.status === "pending" && "Testing Jev classification"}
|
||||
{jevResult.status === "success" && "Jev classification succeeded"}
|
||||
{jevResult.status === "pending" && `Testing ${classifierLabel} classification`}
|
||||
{jevResult.status === "success" && `${classifierLabel} classification succeeded`}
|
||||
{jevResult.status === "error" && jevResult.error}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ describe("buildComplexityRouterConfig", () => {
|
|||
classifier_type: "jev",
|
||||
jev_classifier_config: { model: "jev-latest", timeout_ms: 3000, ...patch },
|
||||
}),
|
||||
).toBe("Enter a JEV model, a positive whole-number timeout and a positive cooldown");
|
||||
).toBe("Enter a decision model, a positive whole-number timeout and a positive cooldown");
|
||||
});
|
||||
|
||||
it.each([false, true])("serializes JEV with shared context and no LLM config, custom tiers: %s", (custom) => {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import type { ModelGroup } from "../llm_calls/fetch_models";
|
|||
import { KeywordTierRule } from "./KeywordTierRules";
|
||||
import {
|
||||
type JevClassifierConfig,
|
||||
jevClassifierConfigSchema,
|
||||
jevClassifierFormConfigSchema,
|
||||
normalizeJevClassifierConfig,
|
||||
} from "./jev_classifier_config";
|
||||
import {
|
||||
|
|
@ -437,8 +437,8 @@ export const getClassifierModelError = (
|
|||
>,
|
||||
): string | null => {
|
||||
if (effectiveClassifierType(config) === "jev") {
|
||||
const parsed = jevClassifierConfigSchema.safeParse(config.jev_classifier_config ?? {});
|
||||
return parsed.success ? null : "Enter a JEV model, a positive whole-number timeout and a positive cooldown";
|
||||
const parsed = jevClassifierFormConfigSchema.safeParse(config.jev_classifier_config ?? {});
|
||||
return parsed.success ? null : "Enter a decision model, a positive whole-number timeout and a positive cooldown";
|
||||
}
|
||||
if (!usesLlmClassifier(effectiveClassifierType(config)) || config.classifier_llm_config?.model) return null;
|
||||
return config.custom_tier_set
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
jevClassifierConfigSchema,
|
||||
jevClassifierFormConfigSchema,
|
||||
normalizeJevClassifierConfig,
|
||||
transitionDecisionModelProvider,
|
||||
} from "./jev_classifier_config";
|
||||
|
||||
const nimble = { provider: "bespoke_nimble" as const, model: "nimble-latest", timeout_ms: 3000 };
|
||||
|
||||
describe("decision model connections", () => {
|
||||
it("keeps new connection overrides but strips saved credentials on hydration", () => {
|
||||
const supplied = { ...nimble, api_base: " https://nimble.example.com ", api_key: " new-key " };
|
||||
expect(normalizeJevClassifierConfig(jevClassifierFormConfigSchema.parse(supplied))).toEqual({
|
||||
...nimble,
|
||||
api_base: "https://nimble.example.com",
|
||||
api_key: "new-key",
|
||||
});
|
||||
expect(jevClassifierConfigSchema.parse(supplied)).toEqual(nimble);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ api_base: "", api_key: " " }, {}],
|
||||
[{ api_key: null }, { api_key: null }],
|
||||
[
|
||||
{ api_base: null, api_key: null },
|
||||
{ api_base: null, api_key: null },
|
||||
],
|
||||
])("distinguishes untouched fields from explicit connection resets: %j", (input, expected) => {
|
||||
expect(normalizeJevClassifierConfig({ ...nimble, ...input })).toEqual({ ...nimble, ...expected });
|
||||
});
|
||||
|
||||
it("drops credentials when changing providers and keeps tuning settings", () => {
|
||||
const jev = { model: "custom-jev", timeout_ms: 9000, api_base: "https://jev.example.com", api_key: "jev-key" };
|
||||
const switched = transitionDecisionModelProvider(jev, "bespoke_nimble");
|
||||
expect(switched).toEqual({ ...nimble, timeout_ms: 9000 });
|
||||
expect(transitionDecisionModelProvider({ ...switched, api_key: "nimble-key" }, "typesafe")).toEqual({
|
||||
provider: "typesafe",
|
||||
model: "jev-latest",
|
||||
timeout_ms: 9000,
|
||||
});
|
||||
expect(transitionDecisionModelProvider(jev, "typesafe")).toBe(jev);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const jevClassifierConfigFields = {
|
||||
provider: z.enum(["typesafe", "bespoke_nimble"]).optional(),
|
||||
model: z.string().trim().min(1).default("jev-latest"),
|
||||
timeout_ms: z.number().int().positive().default(3000),
|
||||
instructions: z
|
||||
|
|
@ -13,15 +14,36 @@ const jevClassifierConfigFields = {
|
|||
|
||||
export const jevClassifierConfigSchema = z.object(jevClassifierConfigFields);
|
||||
|
||||
export type JevClassifierConfig = z.infer<typeof jevClassifierConfigSchema>;
|
||||
export const jevClassifierFormConfigSchema = z.object({
|
||||
...jevClassifierConfigFields,
|
||||
api_base: z.string().trim().nullish(),
|
||||
api_key: z.string().trim().nullish(),
|
||||
});
|
||||
|
||||
export type JevClassifierConfig = z.infer<typeof jevClassifierFormConfigSchema>;
|
||||
export type DecisionModelProvider = NonNullable<JevClassifierConfig["provider"]>;
|
||||
|
||||
export const defaultJevClassifierConfig = (): JevClassifierConfig => jevClassifierConfigSchema.parse({});
|
||||
|
||||
export const transitionDecisionModelProvider = (
|
||||
config: JevClassifierConfig,
|
||||
provider: DecisionModelProvider,
|
||||
): JevClassifierConfig => {
|
||||
if ((config.provider ?? "typesafe") === provider) return config;
|
||||
const { api_base, api_key, ...settings } = config;
|
||||
return { ...settings, provider, model: provider === "bespoke_nimble" ? "nimble-latest" : "jev-latest" };
|
||||
};
|
||||
|
||||
export const normalizeJevClassifierConfig = (
|
||||
config: JevClassifierConfig = defaultJevClassifierConfig(),
|
||||
): JevClassifierConfig => ({
|
||||
...(config.provider !== undefined && { provider: config.provider }),
|
||||
model: config.model.trim(),
|
||||
timeout_ms: config.timeout_ms,
|
||||
...(config.api_base === null && { api_base: null }),
|
||||
...(config.api_base?.trim() && { api_base: config.api_base.trim() }),
|
||||
...(config.api_key === null && { api_key: null }),
|
||||
...(config.api_key?.trim() && { api_key: config.api_key.trim() }),
|
||||
...(config.instructions?.trim() && { instructions: config.instructions.trim() }),
|
||||
...(config.circuit_breaker_enabled !== undefined && { circuit_breaker_enabled: config.circuit_breaker_enabled }),
|
||||
...(config.circuit_breaker_cooldown_seconds !== undefined && {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,35 @@ const hydratedState: KeywordMatchingState = {
|
|||
};
|
||||
|
||||
describe("buildUpdatedComplexityRouterConfig keyword matching", () => {
|
||||
it("preserves Nimble through dashboard validation and saves without exposing transport credentials", () => {
|
||||
const stored = {
|
||||
classifier_type: "jev" as const,
|
||||
tiers: FORM_VALUE.tiers,
|
||||
jev_classifier_config: {
|
||||
provider: "bespoke_nimble" as const,
|
||||
model: "nimble-latest",
|
||||
timeout_ms: 6100,
|
||||
api_key: "masked-key",
|
||||
api_base: "https://nimble.example.com",
|
||||
},
|
||||
};
|
||||
const hydrated = hydrateComplexityRouterConfig(stored, undefined);
|
||||
expect(hydrated.jev_classifier_config).toEqual({
|
||||
provider: "bespoke_nimble",
|
||||
model: "nimble-latest",
|
||||
timeout_ms: 6100,
|
||||
});
|
||||
const edited = {
|
||||
...hydrated,
|
||||
jev_classifier_config: { ...hydrated.jev_classifier_config!, timeout_ms: 900 },
|
||||
};
|
||||
expect(buildUpdatedComplexityRouterConfig(stored, edited).jev_classifier_config).toEqual({
|
||||
provider: "bespoke_nimble",
|
||||
model: "nimble-latest",
|
||||
timeout_ms: 900,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([false, true])("omits masked JEV credentials from dashboard saves, edited: %s", (edited) => {
|
||||
const stored = {
|
||||
classifier_type: "jev" as const,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,15 @@ const forecast = {
|
|||
};
|
||||
|
||||
describe("RoutingDecisionCard", () => {
|
||||
it.each([
|
||||
["bespoke_nimble/nimble-latest", "Nimble classifier"],
|
||||
["typesafe/jev-latest", "JEV classifier"],
|
||||
])("labels a decision from %s", (classifier_model, label) => {
|
||||
render(<RoutingDecisionCard decision={{ cause: "jev_classifier", classifier_model, tier: "SIMPLE" }} />);
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
expect(screen.getByText(classifier_model)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when the request carried no routing decision", () => {
|
||||
const { container } = render(<RoutingDecisionCard decision={undefined} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ function describeCause(decision: RoutingDecision): string {
|
|||
case "llm_classifier":
|
||||
return classifierModel ? `LLM classifier (${classifierModel})` : "LLM classifier";
|
||||
case "jev_classifier":
|
||||
return "JEV classifier";
|
||||
return classifierModel?.startsWith("bespoke_nimble/") ? "Nimble classifier" : "JEV classifier";
|
||||
case "literal_keyword_match":
|
||||
case "keyword":
|
||||
return matchedKeyword ? `Keyword match: "${matchedKeyword}"` : "Keyword match";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue