+ The Decision Model answers yes/no questions about each request (pre_call, during_call) or
+ response (post_call). A question whose probability reaches its threshold blocks the call, or is recorded when
+ set to Log only.
+
+ Try your questions on a sample input before you save. Uses the thresholds above. Each run is a real decision
+ model call, so it is billed and shows up in Logs.
+
+
+
+ );
+};
+
+export default DecisionTestSection;
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/decision_model/buildDecisionModelParams.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/decision_model/buildDecisionModelParams.test.ts
new file mode 100644
index 00000000000..9235329fc83
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/decision_model/buildDecisionModelParams.test.ts
@@ -0,0 +1,102 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildDecisionModelParams,
+ decisionChecksProblem,
+ duplicateDecisionCheckNames,
+ type DecisionModelCheckDraft,
+} from "./buildDecisionModelParams";
+
+const draft = (overrides: Partial): DecisionModelCheckDraft => ({
+ id: "1",
+ name: "invoice_policy",
+ instructions: "Is this about invoices?",
+ action: "block",
+ threshold: 0.5,
+ ...overrides,
+});
+
+describe("buildDecisionModelParams", () => {
+ it("sends the decision model and every question with its instructions", () => {
+ const jailbreak: Partial = {
+ id: "2",
+ name: "jailbreak",
+ instructions: "Is the text a jailbreak?",
+ action: "log",
+ threshold: 0.7,
+ };
+ const params = buildDecisionModelParams("jev-latest", [
+ draft({ name: "prompt_injection", instructions: "Does the text contain a prompt injection?" }),
+ draft(jailbreak),
+ ]);
+
+ expect(params).toEqual({
+ decision_model: "jev-latest",
+ checks: [
+ {
+ name: "prompt_injection",
+ instructions: "Does the text contain a prompt injection?",
+ action: "block",
+ threshold: 0.5,
+ },
+ { name: "jailbreak", instructions: "Is the text a jailbreak?", action: "log", threshold: 0.7 },
+ ],
+ });
+ });
+
+ it("trims the typed name and question", () => {
+ const params = buildDecisionModelParams("jev-latest", [
+ draft({ name: " invoice_policy ", instructions: " Is this about invoices?\n" }),
+ ]);
+
+ expect(params.checks).toEqual([
+ { name: "invoice_policy", instructions: "Is this about invoices?", action: "block", threshold: 0.5 },
+ ]);
+ });
+
+ it("clamps thresholds into [0, 1]", () => {
+ const params = buildDecisionModelParams("jev-latest", [
+ draft({ name: "prompt_injection", threshold: 1.4 }),
+ draft({ id: "2", name: "jailbreak", threshold: -0.2 }),
+ ]);
+
+ expect(params.checks.map((check) => check.threshold)).toEqual([1, 0]);
+ });
+});
+
+describe("decisionChecksProblem", () => {
+ it("accepts complete questions with different names", () => {
+ expect(decisionChecksProblem([draft({}), draft({ id: "2", name: "refund_policy" })])).toBeNull();
+ });
+
+ it("asks for a question when there is none", () => {
+ expect(decisionChecksProblem([])).toBe("Add at least one question");
+ });
+
+ it("rejects a question with a blank name or question", () => {
+ const message = "Give every question a name and a question, or remove it";
+ expect(decisionChecksProblem([draft({ name: " " })])).toBe(message);
+ expect(decisionChecksProblem([draft({}), draft({ id: "2", name: "refund_policy", instructions: "" })])).toBe(
+ message,
+ );
+ });
+
+ it("rejects two questions with the same name", () => {
+ expect(decisionChecksProblem([draft({}), draft({ id: "2", name: " invoice_policy" })])).toBe(
+ "Each question needs a different name",
+ );
+ });
+});
+
+describe("duplicateDecisionCheckNames", () => {
+ it("returns names shared by more than one question, ignoring blank names", () => {
+ const checks = [
+ draft({}),
+ draft({ id: "2", name: "invoice_policy " }),
+ draft({ id: "3", name: "refund_policy" }),
+ draft({ id: "5", name: "" }),
+ draft({ id: "6", name: "" }),
+ ];
+
+ expect([...duplicateDecisionCheckNames(checks)]).toEqual(["invoice_policy"]);
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/decision_model/buildDecisionModelParams.ts b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/decision_model/buildDecisionModelParams.ts
new file mode 100644
index 00000000000..7356cb181fe
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/decision_model/buildDecisionModelParams.ts
@@ -0,0 +1,55 @@
+export interface DecisionModelCheckDraft {
+ id: string;
+ name: string;
+ instructions: string;
+ action: "block" | "log";
+ threshold: number;
+}
+
+export interface DecisionModelLitellmParams {
+ decision_model: string;
+ checks: Array<{
+ name: string;
+ instructions: string;
+ action: "block" | "log";
+ threshold: number;
+ }>;
+}
+
+export function trimDecisionChecks(checks: readonly DecisionModelCheckDraft[]): readonly DecisionModelCheckDraft[] {
+ return checks.map((check) => ({ ...check, name: check.name.trim(), instructions: check.instructions.trim() }));
+}
+
+export function duplicateDecisionCheckNames(checks: readonly DecisionModelCheckDraft[]): ReadonlySet {
+ const names = trimDecisionChecks(checks)
+ .map((check) => check.name)
+ .filter(Boolean);
+ return new Set(names.filter((name, index) => names.indexOf(name) !== index));
+}
+
+export function decisionChecksProblem(checks: readonly DecisionModelCheckDraft[]): string | null {
+ const trimmed = trimDecisionChecks(checks);
+ if (trimmed.length === 0) return "Add at least one question";
+ if (trimmed.some((check) => !check.name || !check.instructions)) {
+ return "Give every question a name and a question, or remove it";
+ }
+ if (duplicateDecisionCheckNames(checks).size > 0) return "Each question needs a different name";
+ return null;
+}
+
+const clampThreshold = (value: number): number => Math.min(1, Math.max(0, value));
+
+export function buildDecisionModelParams(
+ decisionModel: string,
+ checks: readonly DecisionModelCheckDraft[],
+): DecisionModelLitellmParams {
+ return {
+ decision_model: decisionModel,
+ checks: trimDecisionChecks(checks).map((check) => ({
+ name: check.name,
+ instructions: check.instructions,
+ action: check.action,
+ threshold: clampThreshold(check.threshold),
+ })),
+ };
+}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/decision_model/decisionModelQuestion.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/decision_model/decisionModelQuestion.test.ts
new file mode 100644
index 00000000000..84cff497ac0
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/decision_model/decisionModelQuestion.test.ts
@@ -0,0 +1,302 @@
+import { describe, expect, it } from "vitest";
+import type { DecisionModelCheckDraft } from "./buildDecisionModelParams";
+import {
+ buildDecisionTestBody,
+ DECISION_TEST_CHIP_TONE,
+ DECISION_TEST_HISTORY_CAP,
+ decisionModelOptions,
+ decisionTestChip,
+ decisionTestOverall,
+ newDecisionCheckDraft,
+ parseDecisionModelGroups,
+ parseDecisionTestResponse,
+ prependTestRun,
+ runTestShortcutLabel,
+ visibleTestResults,
+ type DecisionTestRun,
+ type DecisionTestScores,
+} from "./decisionModelQuestion";
+
+const groups = [
+ { model_group: "jev-latest", providers: ["typesafe"], mode: "evaluation" },
+ { model_group: "clef", providers: ["cloudflare"], mode: "evaluation" },
+ { model_group: "gpt-5", providers: ["openai"], mode: "chat" },
+ { model_group: "no-provider", mode: "evaluation" },
+];
+
+const draft = (overrides: Partial): DecisionModelCheckDraft => ({
+ id: "1",
+ name: "invoice_policy",
+ instructions: "Is this about invoices?",
+ action: "block",
+ threshold: 0.5,
+ ...overrides,
+});
+
+describe("parseDecisionModelGroups", () => {
+ it("returns no groups when the response data is not a list", () => {
+ expect(parseDecisionModelGroups({})).toEqual([]);
+ expect(parseDecisionModelGroups(undefined)).toEqual([]);
+ });
+
+ it("skips entries without a model group name and normalizes providers and mode", () => {
+ expect(
+ parseDecisionModelGroups([
+ null,
+ { providers: ["typesafe"] },
+ { model_group: "jev-latest", providers: ["typesafe", 7], mode: "evaluation" },
+ { model_group: "bare", providers: "typesafe", mode: 3 },
+ ]),
+ ).toEqual([
+ { model_group: "jev-latest", providers: ["typesafe"], mode: "evaluation" },
+ { model_group: "bare", providers: [], mode: null },
+ ]);
+ });
+});
+
+describe("decisionModelOptions", () => {
+ const allowed = ["typesafe", "openrouter", "cloudflare", "openai"];
+
+ it("lists every evaluation model on an allowed provider with that provider, sorted by name", () => {
+ const mixed = [
+ { model_group: "zeta-eval", providers: ["typesafe"], mode: "evaluation" },
+ ...groups,
+ { model_group: "nimble", providers: ["bespoke"], mode: "evaluation" },
+ ];
+
+ expect(decisionModelOptions(allowed, mixed, null)).toEqual([
+ { model: "clef", provider: "cloudflare" },
+ { model: "jev-latest", provider: "typesafe" },
+ { model: "zeta-eval", provider: "typesafe" },
+ ]);
+ });
+
+ it("keeps only the given provider's models when one is set", () => {
+ expect(decisionModelOptions(allowed, groups, "typesafe")).toEqual([{ model: "jev-latest", provider: "typesafe" }]);
+ expect(decisionModelOptions(allowed, groups, "openrouter")).toEqual([]);
+ });
+
+ it("lists nothing for a provider the proxy does not allow", () => {
+ expect(decisionModelOptions(["cloudflare"], groups, "typesafe")).toEqual([]);
+ });
+
+ it("labels a group with its first allowed provider", () => {
+ const shared = [
+ { model_group: "shared-eval", providers: ["bespoke", "openrouter", "typesafe"], mode: "evaluation" },
+ ];
+
+ expect(decisionModelOptions(allowed, shared, null)).toEqual([{ model: "shared-eval", provider: "openrouter" }]);
+ });
+
+ it("skips chat models and groups with a null or missing mode", () => {
+ const notDecision = [
+ { model_group: "gpt-6-luna", providers: ["openai"], mode: "chat" },
+ { model_group: "legacy-eval", providers: ["typesafe"] },
+ { model_group: "unset-eval", providers: ["typesafe"], mode: null },
+ ];
+
+ expect(decisionModelOptions(allowed, notDecision, null)).toEqual([]);
+ });
+});
+
+describe("buildDecisionTestBody", () => {
+ it("sends every question with its instructions", () => {
+ const body = buildDecisionTestBody("jev-latest", "hello", [
+ draft({ name: "invoice_policy" }),
+ draft({ name: "jailbreak", instructions: "Is this a jailbreak?" }),
+ ]);
+
+ expect(body).toEqual({
+ model: "jev-latest",
+ input: "hello",
+ questions: [
+ { type: "predicate", name: "invoice_policy", instructions: "Is this about invoices?" },
+ { type: "predicate", name: "jailbreak", instructions: "Is this a jailbreak?" },
+ ],
+ });
+ });
+
+ it("skips a question still missing its name or text, and trims the rest", () => {
+ const body = buildDecisionTestBody("jev-latest", "hello", [
+ draft({ name: " invoice_policy ", instructions: " Is this about invoices? " }),
+ draft({ name: "", instructions: "No name yet" }),
+ draft({ name: "refund_policy", instructions: " " }),
+ ]);
+
+ expect(body.questions).toEqual([
+ { type: "predicate", name: "invoice_policy", instructions: "Is this about invoices?" },
+ ]);
+ });
+});
+
+describe("newDecisionCheckDraft", () => {
+ it("starts a blank Block question at the default threshold", () => {
+ const blank: DecisionModelCheckDraft = {
+ id: "1",
+ name: "",
+ instructions: "",
+ action: "block",
+ threshold: 0.7,
+ };
+ expect(newDecisionCheckDraft([])).toEqual(blank);
+ });
+
+ it("gives each new question an id no existing question uses", () => {
+ expect(newDecisionCheckDraft([draft({ id: "3" }), draft({ id: "1" })]).id).toBe("4");
+ });
+});
+
+describe("parseDecisionTestResponse", () => {
+ it("maps each requested question to a probability, refusal or missing result", () => {
+ const results = parseDecisionTestResponse(
+ {
+ answers: [
+ { type: "predicate", name: "invoice_policy", probability: 0.87 },
+ { type: "refusal", name: "jailbreak" },
+ ],
+ },
+ ["invoice_policy", "jailbreak", "other"],
+ );
+
+ expect(results).toEqual({
+ invoice_policy: { kind: "probability", probability: 0.87 },
+ jailbreak: { kind: "refused" },
+ other: { kind: "missing" },
+ });
+ });
+});
+
+describe("decisionTestChip", () => {
+ it("reads the current action and threshold", () => {
+ const result = { kind: "probability" as const, probability: 0.6 };
+
+ expect(decisionTestChip(result, draft({ action: "block", threshold: 0.5 }))).toBe("block");
+ expect(decisionTestChip(result, draft({ action: "log", threshold: 0.5 }))).toBe("logged");
+ expect(decisionTestChip(result, draft({ action: "block", threshold: 0.9 }))).toBe("pass");
+ });
+
+ it("blocks an unanswered block question, as the saved fail-closed guardrail does", () => {
+ expect(decisionTestChip({ kind: "refused" }, draft({ action: "block" }))).toBe("block");
+ expect(decisionTestChip({ kind: "missing" }, draft({ action: "block" }))).toBe("block");
+ expect(decisionTestChip({ kind: "refused" }, draft({ action: "log" }))).toBe("no_answer");
+ expect(decisionTestChip({ kind: "missing" }, draft({ action: "log" }))).toBe("no_answer");
+ });
+});
+
+describe("DECISION_TEST_CHIP_TONE", () => {
+ it("shows Pass in the success tone and Block in the error tone", () => {
+ expect(DECISION_TEST_CHIP_TONE.pass).toBe("success");
+ expect(DECISION_TEST_CHIP_TONE.block).toBe("error");
+ expect(DECISION_TEST_CHIP_TONE.logged).toBe("warning");
+ expect(DECISION_TEST_CHIP_TONE.no_answer).toBe("neutral");
+ });
+});
+
+const scores = (results: DecisionTestScores["results"], checks: DecisionModelCheckDraft[]): DecisionTestScores => ({
+ model: "jev-latest",
+ asked: Object.fromEntries(checks.map((check) => [check.name, check.instructions])),
+ results,
+});
+
+describe("decisionTestOverall", () => {
+ it("is block when any result would block under current thresholds", () => {
+ const checks = [draft({ name: "invoice_policy", threshold: 0.9 }), draft({ name: "jailbreak", threshold: 0.5 })];
+ const run = scores(
+ {
+ invoice_policy: { kind: "probability", probability: 0.6 },
+ jailbreak: { kind: "probability", probability: 0.8 },
+ },
+ checks,
+ );
+ const raised = checks.map((check) => (check.name === "jailbreak" ? { ...check, threshold: 0.9 } : check));
+
+ expect(decisionTestOverall(visibleTestResults(run, checks, "jev-latest"))).toBe("block");
+ expect(decisionTestOverall(visibleTestResults(run, raised, "jev-latest"))).toBe("pass");
+ });
+
+ it("is block when a block question got no answer", () => {
+ const checks = [draft({ name: "jailbreak" })];
+ const run = scores({ jailbreak: { kind: "missing" } }, checks);
+
+ expect(decisionTestOverall(visibleTestResults(run, checks, "jev-latest"))).toBe("block");
+ });
+
+ it("ignores a question removed after the run", () => {
+ const checks = [draft({ name: "invoice_policy", threshold: 0.9 }), draft({ name: "jailbreak" })];
+ const run = scores(
+ {
+ invoice_policy: { kind: "probability", probability: 0.1 },
+ jailbreak: { kind: "probability", probability: 0.99 },
+ },
+ checks,
+ );
+
+ expect(decisionTestOverall(visibleTestResults(run, [checks[0]], "jev-latest"))).toBe("pass");
+ });
+});
+
+describe("visibleTestResults", () => {
+ it("drops results for questions deleted since the run", () => {
+ const checks = [draft({ name: "invoice_policy" }), draft({ name: "deleted_question" })];
+ const run = scores(
+ {
+ invoice_policy: { kind: "probability", probability: 0.9 },
+ deleted_question: { kind: "probability", probability: 0.1 },
+ },
+ checks,
+ );
+
+ expect(visibleTestResults(run, [draft({ name: "invoice_policy" })], "jev-latest")).toEqual([
+ { check: draft({ name: "invoice_policy" }), result: { kind: "probability", probability: 0.9 } },
+ ]);
+ });
+
+ it("drops a score whose question text changed since the run, even under the same name", () => {
+ const run = scores({ invoice_policy: { kind: "probability", probability: 0.9 } }, [draft({})]);
+
+ expect(visibleTestResults(run, [draft({ instructions: "Does the text ask for a refund?" })], "jev-latest")).toEqual(
+ [],
+ );
+ });
+
+ it("drops every score when the decision model changed since the run", () => {
+ const run = scores({ invoice_policy: { kind: "probability", probability: 0.9 } }, [draft({})]);
+
+ expect(visibleTestResults(run, [draft({})], "pplx-decider-v1-27b")).toEqual([]);
+ });
+
+ it("keeps a score when only the threshold or action changed", () => {
+ const run = scores({ invoice_policy: { kind: "probability", probability: 0.9 } }, [draft({})]);
+ const retuned = draft({ threshold: 0.95, action: "log" });
+
+ expect(visibleTestResults(run, [retuned], "jev-latest")).toEqual([
+ { check: retuned, result: { kind: "probability", probability: 0.9 } },
+ ]);
+ });
+});
+
+describe("runTestShortcutLabel", () => {
+ it("shows the command key on Mac and Ctrl elsewhere", () => {
+ expect(runTestShortcutLabel("MacIntel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15)")).toBe("⌘+Enter");
+ expect(runTestShortcutLabel("Win32 Mozilla/5.0 (Windows NT 10.0)")).toBe("Ctrl+Enter");
+ });
+});
+
+describe("prependTestRun", () => {
+ it("prepends the latest run and caps history at 20", () => {
+ const run: DecisionTestRun = { id: 1, input: "new", model: "jev-latest", asked: {}, results: {} };
+ const old = Array.from({ length: DECISION_TEST_HISTORY_CAP }, (_, i) => ({
+ id: i + 2,
+ input: `old ${i}`,
+ model: "jev-latest",
+ asked: {},
+ results: {},
+ }));
+
+ const runs = prependTestRun(old, run);
+
+ expect(runs[0]).toBe(run);
+ expect(runs).toHaveLength(DECISION_TEST_HISTORY_CAP);
+ expect(runs.at(-1)).toEqual(old[DECISION_TEST_HISTORY_CAP - 2]);
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/decision_model/decisionModelQuestion.ts b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/decision_model/decisionModelQuestion.ts
new file mode 100644
index 00000000000..0c04a5ad22d
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/decision_model/decisionModelQuestion.ts
@@ -0,0 +1,175 @@
+import type { StatusTone } from "@/components/shared/table_cells";
+import { isDecisionMode } from "@/lib/decisionModels";
+
+import { trimDecisionChecks, type DecisionModelCheckDraft } from "./buildDecisionModelParams";
+
+export interface DecisionModelGroup {
+ model_group: string;
+ providers?: string[] | null;
+ mode?: string | null;
+}
+
+export function parseDecisionModelGroups(data: unknown): DecisionModelGroup[] {
+ if (!Array.isArray(data)) return [];
+ return data.flatMap((entry: unknown) => {
+ if (typeof entry !== "object" || entry === null) return [];
+ const { model_group, providers, mode } = entry as Record;
+ if (typeof model_group !== "string") return [];
+ return [
+ {
+ model_group,
+ providers: Array.isArray(providers) ? providers.filter((p): p is string => typeof p === "string") : [],
+ mode: typeof mode === "string" ? mode : null,
+ },
+ ];
+ });
+}
+
+export interface DecisionModelOption {
+ model: string;
+ provider: string;
+}
+
+export function decisionModelOptions(
+ allowedProviders: readonly string[],
+ groups: readonly DecisionModelGroup[],
+ onlyProvider: string | null,
+): DecisionModelOption[] {
+ const providers = onlyProvider ? allowedProviders.filter((provider) => provider === onlyProvider) : allowedProviders;
+ return groups
+ .filter((group) => isDecisionMode(group.mode))
+ .flatMap((group) => {
+ const provider = (group.providers ?? []).find((candidate) => providers.includes(candidate));
+ return provider ? [{ model: group.model_group, provider }] : [];
+ })
+ .toSorted((a, b) => a.model.localeCompare(b.model));
+}
+
+export interface DecisionTestBody {
+ model: string;
+ input: string;
+ questions: Array<{ type: "predicate"; name: string; instructions: string }>;
+}
+
+export function runnableDecisionQuestions(checks: readonly DecisionModelCheckDraft[]): DecisionModelCheckDraft[] {
+ return trimDecisionChecks(checks).filter((check) => check.name && check.instructions);
+}
+
+export function buildDecisionTestBody(
+ model: string,
+ input: string,
+ checks: readonly DecisionModelCheckDraft[],
+): DecisionTestBody {
+ return {
+ model,
+ input,
+ questions: runnableDecisionQuestions(checks).map((check) => ({
+ type: "predicate" as const,
+ name: check.name,
+ instructions: check.instructions,
+ })),
+ };
+}
+
+export type DecisionTestResult =
+ | { kind: "probability"; probability: number }
+ | { kind: "refused" }
+ | { kind: "missing" };
+
+export type DecisionTestResults = Record;
+
+export interface DecisionTestScores {
+ model: string;
+ asked: Record;
+ results: DecisionTestResults;
+}
+
+export type DecisionTestRun =
+ | ({ id: number; input: string } & DecisionTestScores)
+ | { id: number; input: string; error: string };
+
+interface DecisionsApiAnswer {
+ type?: string;
+ name?: string | null;
+ probability?: number;
+}
+
+export function parseDecisionTestResponse(body: unknown, questionNames: readonly string[]): DecisionTestResults {
+ const answers =
+ typeof body === "object" && body !== null ? (body as { answers?: DecisionsApiAnswer[] }).answers ?? [] : [];
+ const byName = new Map(answers.filter((answer) => answer.name != null).map((answer) => [answer.name, answer]));
+ const results: DecisionTestResults = {};
+ for (const name of questionNames) {
+ const answer = byName.get(name);
+ if (!answer) {
+ results[name] = { kind: "missing" };
+ } else if (answer.type === "refusal") {
+ results[name] = { kind: "refused" };
+ } else if (typeof answer.probability === "number") {
+ results[name] = { kind: "probability", probability: answer.probability };
+ } else {
+ results[name] = { kind: "missing" };
+ }
+ }
+ return results;
+}
+
+export type DecisionTestChip = "block" | "pass" | "logged" | "no_answer";
+
+export const DECISION_TEST_CHIP_TONE: Readonly> = {
+ block: "error",
+ pass: "success",
+ logged: "warning",
+ no_answer: "neutral",
+};
+
+export function decisionTestChip(result: DecisionTestResult, check: DecisionModelCheckDraft): DecisionTestChip {
+ if (result.kind !== "probability") return check.action === "block" ? "block" : "no_answer";
+ if (result.probability >= check.threshold) return check.action === "block" ? "block" : "logged";
+ return "pass";
+}
+
+export interface VisibleTestResult {
+ check: DecisionModelCheckDraft;
+ result: DecisionTestResult;
+}
+
+export function decisionTestOverall(visible: readonly VisibleTestResult[]): "block" | "pass" {
+ return visible.some(({ check, result }) => decisionTestChip(result, check) === "block") ? "block" : "pass";
+}
+
+export function visibleTestResults(
+ scores: DecisionTestScores,
+ checks: readonly DecisionModelCheckDraft[],
+ model: string,
+): VisibleTestResult[] {
+ if (scores.model !== model) return [];
+ return trimDecisionChecks(checks).flatMap((check) => {
+ const result = scores.results[check.name];
+ return result && scores.asked[check.name] === check.instructions ? [{ check, result }] : [];
+ });
+}
+
+export function runTestShortcutLabel(platform?: string): string {
+ const hint = platform ?? (typeof navigator === "undefined" ? "" : `${navigator.platform} ${navigator.userAgent}`);
+ return /mac/i.test(hint) ? "⌘+Enter" : "Ctrl+Enter";
+}
+
+export const DEFAULT_DECISION_THRESHOLD = 0.7;
+
+export function newDecisionCheckDraft(existing: readonly DecisionModelCheckDraft[]): DecisionModelCheckDraft {
+ const lastId = Math.max(0, ...existing.map((check) => Number(check.id) || 0));
+ return {
+ id: String(lastId + 1),
+ name: "",
+ instructions: "",
+ action: "block",
+ threshold: DEFAULT_DECISION_THRESHOLD,
+ };
+}
+
+export const DECISION_TEST_HISTORY_CAP = 20;
+
+export function prependTestRun(runs: readonly DecisionTestRun[], run: DecisionTestRun): DecisionTestRun[] {
+ return [run, ...runs].slice(0, DECISION_TEST_HISTORY_CAP);
+}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden.test.tsx
index e20c8a6fa55..613fee04f68 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden.test.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, screen } from "@testing-library/react";
+import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import GuardrailGarden from "./guardrail_garden";
import { ALL_CARDS } from "./guardrail_garden_data";
@@ -16,6 +16,7 @@ vi.mock("./guardrail_garden_detail", () => ({
const LITELLM_CARDS = ALL_CARDS.filter((c) => c.category === "litellm");
const PARTNER_CARDS = ALL_CARDS.filter((c) => c.category === "partner");
+const DECISION_MODEL_CARDS = ALL_CARDS.filter((c) => c.category === "decision_model");
describe("GuardrailGarden", () => {
beforeEach(() => {
@@ -53,6 +54,44 @@ describe("GuardrailGarden", () => {
expect(screen.getByText(LITELLM_CARDS[LITELLM_CARDS.length - 1].name)).toBeInTheDocument();
});
+ it("should list the decision model cards under their own Decision Model section", () => {
+ renderGarden();
+
+ const sectionOf = (heading: string) => screen.getByRole("region", { name: heading });
+ const names = [
+ "TypeSafe Jev",
+ "Perplexity Decision",
+ "Microsoft Decision Model",
+ "OpenAI Decision Model",
+ "Databricks Decision Model",
+ ];
+ expect(DECISION_MODEL_CARDS.map((card) => card.name)).toEqual(names);
+ for (const name of names) {
+ expect(within(sectionOf("Decision Model")).getByText(name)).toBeInTheDocument();
+ expect(within(sectionOf("Partner Guardrails")).queryByText(name)).not.toBeInTheDocument();
+ }
+ });
+
+ it("should keep only decision model cards when searching for decision models", async () => {
+ const user = userEvent.setup();
+ renderGarden();
+
+ await user.type(screen.getByPlaceholderText("Search guardrails"), "decision model");
+
+ expect(screen.getByText("Databricks Decision Model")).toBeInTheDocument();
+ expect(screen.getByText("TypeSafe Jev")).toBeInTheDocument();
+ expect(screen.queryByText(PARTNER_CARDS[0].name)).not.toBeInTheDocument();
+ });
+
+ it("should open the detail view for a decision model card", async () => {
+ const user = userEvent.setup();
+ renderGarden();
+
+ await user.click(screen.getByText("Perplexity Decision"));
+
+ expect(screen.getByText("Detail for Perplexity Decision")).toBeInTheDocument();
+ });
+
it("should always render every partner card", () => {
renderGarden();
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden.tsx
index a09ce9435bb..f8e1ab1ef66 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_garden.tsx
@@ -28,6 +28,7 @@ const GuardrailGarden: React.FC = ({ accessToken, onGuardr
});
const litellmCards = filteredCards.filter((c) => c.category === "litellm");
+ const decisionModelCards = filteredCards.filter((c) => c.category === "decision_model");
const partnerCards = filteredCards.filter((c) => c.category === "partner");
if (selectedCard) {
@@ -56,9 +57,11 @@ const GuardrailGarden: React.FC = ({ accessToken, onGuardr