feat(ui): offer Nadir as an auto router decision model

Add Auto Router -> Decisions Model now lists Nadir beside Jev, Laya,
Bespoke Nimble and Databricks. Picking it starts the classifier on the
`bucket` label, hides the Classifier Model field (the label only names
log rows) and the Classifier Instructions field, and drops any
instructions carried over from another provider on save, since the
gateway rejects instructions for Nadir. The editor tells the admin to
set NADIR_API_KEY on the gateway, or NADIR_API_BASE for a self-hosted
Nadir.

schema.d.ts gains "nadir" in the opensource_classifier_config provider
enum, matching the backend Literal (the one line `npm run gen:api`
changes for this diff).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
dor 2026-10-18 14:57:39 -04:00 • committed by Dor Amir
parent 7d31f0ac36
commit 78c9a8bc31
9 changed files with 126 additions and 59 deletions

View file

@ -233,6 +233,7 @@ const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({
{ value: "laya", label: "Laya" },
{ value: "bespoke", label: "Bespoke Nimble" },
{ value: "databricks", label: "Databricks" },
{ value: "nadir", label: "Nadir" },
]}
value={normalizeJevClassifierConfig(value.jev_classifier_config).provider}
onValueChange={changeProvider}
@ -245,6 +246,7 @@ const AutoRouterClassifierTabs: React.FC<AutoRouterClassifierTabsProps> = ({
<SelectItem value="laya">Laya</SelectItem>
<SelectItem value="bespoke">Bespoke Nimble</SelectItem>
<SelectItem value="databricks">Databricks</SelectItem>
<SelectItem value="nadir">Nadir</SelectItem>
</SelectContent>
</Select>
</div>

View file

@ -55,7 +55,9 @@ const ClassifierTypeRadios: React.FC<ClassifierTypeRadiosProps> = ({ value, clas
<RadioGroupItem value="jev" className="mt-0.5" />
<span>
<strong className="font-semibold">OSS Classifier</strong>{" "}
<span className="text-muted-foreground">uses Jev, Laya, Bespoke, or Databricks to decide the tier</span>
<span className="text-muted-foreground">
uses Jev, Laya, Bespoke, Databricks, or Nadir to decide the tier
</span>
</span>
</Label>
<SimpleTooltip content={scorerLockedReason}>

View file

@ -164,6 +164,28 @@ describe("JEV classifier editor", () => {
},
);
it("hides the model and instructions for Nadir and saves it without instructions", async () => {
renderWithProviders(<Form />);
fireEvent.click(screen.getByRole("radio", { name: /^Decisions Model$/ }));
fireEvent.change(screen.getByLabelText("Classifier Instructions"), { target: { value: "Pick carefully" } });
await chooseSelectOption(userEvent, screen.getByRole("combobox", { name: "Decision model" }), "Nadir");
expect(screen.queryByLabelText("Classifier Model")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Classifier Instructions")).not.toBeInTheDocument();
expect(screen.getByText(/Set NADIR_API_KEY on the gateway/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Save and reload" }));
expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveTextContent("Nadir");
fireEvent.click(screen.getByRole("button", { name: "Probe current config" }));
expect(testAutoRouterRouting).toHaveBeenLastCalledWith(
"token",
expect.objectContaining({
complexity_router_config: expect.objectContaining({
classifier_type: "oss_classifier",
opensource_classifier_config: { provider: "nadir", model: "bucket", timeout_ms: 3000 },
}),
}),
);
});
it("allows licensed instructions and can restore built-in instructions", () => {
const authorized = useAuthorized();
vi.mocked(useAuthorized).mockReturnValue({ ...authorized, premiumUser: true });

View file

@ -7,7 +7,11 @@ import { Textarea } from "@/components/ui/textarea";
import ClassifierCircuitBreakerConfig from "./ClassifierCircuitBreakerConfig";
import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { defaultJevClassifierConfig, fixedClassifierModels } from "./jev_classifier_config";
import {
acceptsClassifierInstructions,
defaultJevClassifierConfig,
fixedClassifierModels,
} from "./jev_classifier_config";
const providerDescriptions = {
jev: "Uses TypeSafe System One Choice evaluation with your configured tiers",
@ -16,6 +20,8 @@ const providerDescriptions = {
"Uses Bespoke Nimble with your configured tiers. Set BESPOKE_API_BASE on the gateway to connect your Nimble server.",
databricks:
"Uses the Databricks ai_decide AI Function, or a decision serving endpoint you name, with your configured tiers. Set DATABRICKS_API_BASE to https://<workspace-host>/serving-endpoints and DATABRICKS_API_KEY on the gateway.",
nadir:
"Uses Nadir's tier decision with your configured tiers. Set NADIR_API_KEY on the gateway; without it, calls use Nadir's anonymous, rate-limited tier. Set NADIR_API_BASE for a self-hosted Nadir.",
};
export default function JevClassifierConfig({
@ -38,34 +44,36 @@ export default function JevClassifierConfig({
{section !== "advanced" && !config.deployment_name && (
<>
<p className="text-sm text-muted-foreground">{providerDescriptions[config.provider ?? "jev"]}</p>
<div className="space-y-2">
<Label htmlFor={`${id}-model`}>Classifier Model</Label>
{models ? (
<Select value={config.model} onValueChange={(model) => model && update({ model })}>
<SelectTrigger id={`${id}-model`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id={`${id}-model`}
value={config.model}
placeholder={
config.provider === "databricks"
? "ai_decide, or a serving endpoint name such as databricks-openjev-qwen35-4b"
: undefined
}
onChange={(event) => update({ model: event.target.value })}
/>
)}
</div>
{config.provider !== "nadir" && (
<div className="space-y-2">
<Label htmlFor={`${id}-model`}>Classifier Model</Label>
{models ? (
<Select value={config.model} onValueChange={(model) => model && update({ model })}>
<SelectTrigger id={`${id}-model`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id={`${id}-model`}
value={config.model}
placeholder={
config.provider === "databricks"
? "ai_decide, or a serving endpoint name such as databricks-openjev-qwen35-4b"
: undefined
}
onChange={(event) => update({ model: event.target.value })}
/>
)}
</div>
)}
</>
)}
{section !== "model" && (
@ -90,27 +98,29 @@ export default function JevClassifierConfig({
})
}
/>
<div className="space-y-2">
<Label htmlFor={`${id}-instructions`}>Classifier Instructions</Label>
<AutoRouterAllowanceNote
feature="tier_or_classifier_prompt"
label="Custom instructions share the custom-tier allowance"
/>
<Textarea
id={`${id}-instructions`}
value={config.instructions ?? ""}
placeholder="Leave blank to use the built-in instructions"
onChange={(event) => update({ instructions: event.target.value || undefined })}
/>
{config.instructions && (
<Button variant="outline" type="button" onClick={() => update({ instructions: undefined })}>
Restore built-in instructions
</Button>
)}
<p className="text-xs text-muted-foreground">
Built-in decision model classification is available without a license and uses the shipped tier criteria
</p>
</div>
{acceptsClassifierInstructions(config.provider) && (
<div className="space-y-2">
<Label htmlFor={`${id}-instructions`}>Classifier Instructions</Label>
<AutoRouterAllowanceNote
feature="tier_or_classifier_prompt"
label="Custom instructions share the custom-tier allowance"
/>
<Textarea
id={`${id}-instructions`}
value={config.instructions ?? ""}
placeholder="Leave blank to use the built-in instructions"
onChange={(event) => update({ instructions: event.target.value || undefined })}
/>
{config.instructions && (
<Button variant="outline" type="button" onClick={() => update({ instructions: undefined })}>
Restore built-in instructions
</Button>
)}
<p className="text-xs text-muted-foreground">
Built-in decision model classification is available without a license and uses the shipped tier criteria
</p>
</div>
)}
</>
)}
</div>

View file

@ -56,6 +56,8 @@ describe("buildAutoRouterRoutingTestRequest", () => {
["databricks", "databricks-openjev-qwen35-4b", "json"],
["databricks", "ai_decide", "object"],
["databricks", "ai_decide", "json"],
["nadir", "bucket", "object"],
["nadir", "bucket", "json"],
])("probes saved %s/%s %s configuration with custom tiers and team context", (provider, model, format) => {
const config = {
classifier_type: "oss_classifier",

View file

@ -164,6 +164,7 @@ describe("buildComplexityRouterConfig", () => {
["bespoke", "bespokelabs/Bespoke-Nimble-9B"],
["databricks", "databricks-openjev-qwen35-4b"],
["databricks", "ai_decide"],
["nadir", "bucket"],
["jev", "custom-jev-model"],
[undefined, "custom-jev-model"],
] as const)("accepts %s model %s before saving or testing", (provider, model) => {
@ -184,6 +185,8 @@ describe("buildComplexityRouterConfig", () => {
["bespoke", "nimble-latest", true],
["databricks", "databricks-openjev-qwen35-4b", false],
["databricks", "databricks-openjev-qwen35-4b", true],
["nadir", "bucket", false],
["nadir", "bucket", true],
] as const)("serializes %s/%s with shared context and no LLM config, custom tiers: %s", (provider, model, custom) => {
const params: BuildComplexityRouterConfigParams = {
...baseParams,
@ -220,7 +223,8 @@ describe("buildComplexityRouterConfig", () => {
provider,
model,
timeout_ms: 4500,
instructions: "Choose the configured tier",
// Nadir classifies with its own model, and the gateway rejects instructions for it.
...(provider !== "nadir" && { instructions: "Choose the configured tier" }),
circuit_breaker_enabled: false,
circuit_breaker_cooldown_seconds: 12.5,
};

View file

@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { defaultJevClassifierConfig, hydrateOssClassifier, jevClassifierConfigSchema } from "./jev_classifier_config";
import {
defaultJevClassifierConfig,
hydrateOssClassifier,
jevClassifierConfigSchema,
normalizeJevClassifierConfig,
} from "./jev_classifier_config";
describe("hydrateOssClassifier", () => {
it("keeps a stored Databricks classifier instead of falling back to Jev", () => {
@ -35,6 +40,18 @@ describe("jevClassifierConfigSchema", () => {
expect(jevClassifierConfigSchema.safeParse({ ...fresh, model: "my-openjev" }).success).toBe(true);
});
it("starts a Nadir classifier on its bucket label, keeps a stored label, and drops instructions it would reject", () => {
expect(defaultJevClassifierConfig("nadir")).toEqual({ provider: "nadir", model: "bucket", timeout_ms: 3000 });
const stored = { provider: "nadir", model: "nadir-prod", timeout_ms: 4000 };
expect(hydrateOssClassifier({ classifier_type: "oss_classifier", opensource_classifier_config: stored })).toEqual({
classifier_type: "jev",
jev_classifier_config: stored,
});
expect(
normalizeJevClassifierConfig({ ...defaultJevClassifierConfig("nadir"), instructions: "Pick carefully" }),
).toEqual({ provider: "nadir", model: "bucket", timeout_ms: 3000 });
});
it.each(["serving-endpoints/openjev", "openjev?x=1", "openjev#frag", ".", "..", ".openjev", "open jev"])(
"rejects the Databricks endpoint name %j because it would change the request URL",
(model) => {

View file

@ -6,20 +6,27 @@ export const OSS_CLASSIFIER_MODELS = {
bespoke: ["nimble-latest", "nimble", "bespokelabs/Bespoke-Nimble-9B"],
} as const;
const OSS_CLASSIFIER_PROVIDERS = ["jev", "laya", "bespoke", "databricks"] as const;
const OSS_CLASSIFIER_PROVIDERS = ["jev", "laya", "bespoke", "databricks", "nadir"] as const;
export type OssClassifierProvider = (typeof OSS_CLASSIFIER_PROVIDERS)[number];
export const isOssClassifierProvider = (value: unknown): value is OssClassifierProvider =>
OSS_CLASSIFIER_PROVIDERS.some((provider) => provider === value);
const DEFAULT_TIMEOUT_MS = 3000;
const SERVING_ENDPOINT_NAME = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
const DATABRICKS_AI_DECIDE_MODEL = "ai_decide";
const TYPED_MODEL_DEFAULTS: Partial<Record<OssClassifierProvider, string>> = {
databricks: "ai_decide",
nadir: "bucket",
};
export const fixedClassifierModels = (provider: OssClassifierProvider | undefined): readonly string[] | undefined =>
provider === "laya" || provider === "bespoke" ? OSS_CLASSIFIER_MODELS[provider] : undefined;
const defaultClassifierModel = (provider: OssClassifierProvider | undefined): string =>
fixedClassifierModels(provider)?.[0] ?? (provider === "databricks" ? DATABRICKS_AI_DECIDE_MODEL : "jev-latest");
fixedClassifierModels(provider)?.[0] ?? (provider && TYPED_MODEL_DEFAULTS[provider]) ?? "jev-latest";
// Nadir classifies with its own model, so the gateway rejects custom instructions for it.
export const acceptsClassifierInstructions = (provider: OssClassifierProvider | undefined): boolean =>
provider !== "nadir";
const isSupportedClassifierModel = (provider: OssClassifierProvider | undefined, model: string): boolean => {
if (provider === "databricks") return SERVING_ENDPOINT_NAME.test(model);
@ -31,7 +38,7 @@ const jevClassifierConfigFields = {
deployment_name: z.preprocess((value) => value ?? undefined, z.string().trim().min(1).optional()),
provider: z.preprocess(
(value) => (value === "typesafe" ? "jev" : value),
z.enum(["jev", "laya", "bespoke", "databricks"]).optional(),
z.enum(["jev", "laya", "bespoke", "databricks", "nadir"]).optional(),
),
model: z.string().trim().min(1).optional(),
timeout_ms: z.number().int().positive().default(DEFAULT_TIMEOUT_MS),
@ -80,7 +87,8 @@ export const normalizeJevClassifierConfig = (
provider: config.provider ?? "jev",
model: config.model.trim(),
timeout_ms: config.timeout_ms,
...(config.instructions?.trim() && { instructions: config.instructions.trim() }),
...(acceptsClassifierInstructions(config.provider) &&
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

@ -39882,7 +39882,7 @@ export interface components {
* @default jev
* @enum {string}
*/
provider: "jev" | "laya" | "bespoke" | "databricks";
provider: "jev" | "laya" | "bespoke" | "databricks" | "nadir";
/**
* Timeout Ms
* @default 3000