feat(ui): support native decisions endpoint in decision playground (#44664)

* feat(ui): support native decisions endpoint in decision playground

* fix(ui): preserve decision playground defaults and test late cancellation results

* test(ui): remove redundant cancellation test comment

* Update ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneUI.integration.test.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: moyai-devin-berriai[bot] <336287033+moyai-devin-berriai[bot]@users.noreply.github.com>
Co-authored-by: ryan-crabbe-berri <ryan@berri.ai>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
moyai-devin-berriai[bot] 2026-10-06 00:48:10 +00:00 • committed by GitHub
parent aa4852f579
commit 6e75f28289
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 361 additions and 41 deletions

View file

@ -2,7 +2,7 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/cva.config";
import { ROOT_BLOCK_STYLES } from "./lib/rootBlocks";
import type { SystemOneQuestion, SystemOneRequest } from "./lib/schemas";
import type { PlaygroundQuestion, PlaygroundRequest } from "./lib/schemas";
function formatState(state: unknown): string {
if (typeof state === "string") {
@ -11,14 +11,14 @@ function formatState(state: unknown): string {
return JSON.stringify(state, null, 2) ?? String(state);
}
function QuestionCriteria({ question }: { question: SystemOneQuestion }) {
function QuestionCriteria({ question }: { question: PlaygroundQuestion }) {
if (question.type === "choice") {
return (
<dl className="grid gap-2">
{Object.entries(question.criteria).map(([label, description]) => (
<div key={label} className="grid gap-0.5 sm:grid-cols-[minmax(7rem,auto)_1fr] sm:gap-3">
<dt className="font-mono text-xs font-medium">{label}</dt>
<dd className="text-xs text-muted-foreground">{description}</dd>
<dd className="text-xs text-muted-foreground">{formatState(description)}</dd>
</div>
))}
</dl>
@ -48,14 +48,14 @@ function QuestionCriteria({ question }: { question: SystemOneQuestion }) {
{question.criteria.map((description, index) => (
<li key={`${index}-${description}`} className="grid gap-0.5 sm:grid-cols-[minmax(7rem,auto)_1fr] sm:gap-3">
<span className="font-mono text-xs font-medium">{index}</span>
<span className="text-xs text-muted-foreground">{description}</span>
<span className="text-xs text-muted-foreground">{formatState(description)}</span>
</li>
))}
</ol>
);
}
export default function QuestionBreakdown({ payload }: { payload?: SystemOneRequest }) {
export default function QuestionBreakdown({ payload }: { payload?: PlaygroundRequest }) {
if (!payload) {
return (
<Card>
@ -94,7 +94,7 @@ export default function QuestionBreakdown({ payload }: { payload?: SystemOneRequ
</h3>
<Badge variant="secondary">{question.type}</Badge>
</div>
<p className="text-sm">{question.instructions}</p>
{question.instructions != null && <p className="text-sm">{formatState(question.instructions)}</p>}
<div className="grid gap-2">
<h4 className="text-xs font-medium text-muted-foreground">Criteria</h4>
<QuestionCriteria question={question} />

View file

@ -59,6 +59,26 @@ describe("ResponseView", () => {
expect(screen.getByRole("meter", { name: "1 probability" })).toHaveAttribute("aria-valuetext", "0%");
});
it("renders structured decision score legends without coercing objects to strings", () => {
const response: SystemOneResponse = {
answers: {
severity: {
type: "score",
score: 0,
probabilities: { "0": 1 },
legend: { "0": { description: "Low severity" } },
},
},
usage: null,
};
render(<ResponseView response={response} isLoading={false} />);
expect(screen.getByRole("meter", { name: '0: {"description":"Low severity"} probability' })).toHaveAttribute(
"aria-valuenow",
"100",
);
expect(screen.queryByText(/\[object Object\]/)).not.toBeInTheDocument();
});
it("shows an inline error message", () => {
render(<ResponseView error="Upstream request failed" isLoading={false} />);

View file

@ -92,7 +92,11 @@ function AnswerDetails({ answer }: { answer: SystemOneAnswer }) {
</div>
<div className="grid gap-3">
{levels.map(([level, probability]) => {
const label = answer.legend?.[level] ? `${level}: ${answer.legend[level]}` : level;
const description = answer.legend?.[level];
const label =
description === undefined
? level
: `${level}: ${typeof description === "string" ? description : JSON.stringify(description)}`;
return (
<ProbabilityMeter
key={level}

View file

@ -1,5 +1,12 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render as rtlRender, screen, waitForElementToBeRemoved } from "@testing-library/react";
import {
act,
fireEvent,
render as rtlRender,
screen,
waitFor,
waitForElementToBeRemoved,
} from "@testing-library/react";
import type { ReactElement } from "react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@ -63,11 +70,15 @@ describe("SystemOneUI integration", () => {
expect(resetButton).toBeDisabled();
});
it("flags the tab as a TypeSafe-only beta without announcing it as an alert", () => {
it("explains the native decision endpoint after opting in without announcing it as an alert", async () => {
const user = userEvent.setup();
render(<SystemOneUI accessToken="session-key" />);
screen.getByRole("combobox", { name: "Decision endpoint" }).focus();
await user.keyboard("{ArrowDown}");
await user.click(await screen.findByRole("option", { name: "Decisions · /v1/decisions" }));
expect(screen.getByRole("note", { name: "System One beta notice" })).toHaveTextContent(
"Support for more System One-compatible models is in progress.",
expect(screen.getByRole("note", { name: "Decision endpoint notice" })).toHaveTextContent(
"omit model to use the proxy's configured default.",
);
expect(screen.getByRole("link", { name: "Give us feedback on what you want for decision models" })).toHaveAttribute(
"href",
@ -87,7 +98,7 @@ describe("SystemOneUI integration", () => {
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
});
it("posts the request with the session key and renders calibrated answers", async () => {
it("preserves the untouched TypeSafe default and renders calibrated answers", async () => {
const user = userEvent.setup();
sessionStorage.setItem("customProxyBaseUrl", "https://stale.example.com/");
render(<SystemOneUI accessToken="session-key" />);
@ -123,7 +134,7 @@ describe("SystemOneUI integration", () => {
expect(screen.getByText("Selected choice")).toBeInTheDocument();
});
it("keeps the preview usable when question values have invalid types", () => {
it("keeps legacy validation when question values have invalid types", async () => {
render(<SystemOneUI accessToken="session-key" />);
fireEvent.change(screen.getByRole("textbox", { name: "System One JSON payload" }), {
@ -146,6 +157,114 @@ describe("SystemOneUI integration", () => {
expect(screen.getByText("Enter a valid request to preview its state and questions.")).toBeInTheDocument();
});
it("preserves each endpoint draft and sends legacy requests to TypeSafe", async () => {
const user = userEvent.setup();
render(<SystemOneUI accessToken="session-key" />);
screen.getByRole("combobox", { name: "Decision endpoint" }).focus();
await user.keyboard("{ArrowDown}");
await user.click(await screen.findByRole("option", { name: "Decisions · /v1/decisions" }));
const editor = screen.getByRole("textbox", { name: "System One JSON payload" });
const draft = JSON.stringify({
model: "my-decider",
state: {},
questions: {
route: { type: "choice", criteria: { support: { text: "Help" }, other: null } },
},
});
fireEvent.change(editor, { target: { value: draft } });
expect(screen.getByRole("button", { name: "Send" })).toBeEnabled();
expect(screen.getByText(/"text": "Help"/)).toBeInTheDocument();
screen.getByRole("combobox", { name: "Decision endpoint" }).focus();
await user.keyboard("{ArrowDown}");
await user.click(await screen.findByRole("option", { name: "TypeSafe · /typesafe/v1/systemone" }));
await user.click(screen.getByRole("button", { name: "Send" }));
expect(await screen.findByText("Selected choice")).toBeInTheDocument();
expect(mockFetch.mock.calls[0]?.[0]).toMatch(/\/typesafe\/v1\/systemone$/);
expect(JSON.parse(mockFetch.mock.calls[0]?.[1]?.body as string).model).toBe("jev-latest");
screen.getByRole("combobox", { name: "Decision endpoint" }).focus();
await user.keyboard("{ArrowDown}");
await user.click(await screen.findByRole("option", { name: "Decisions · /v1/decisions" }));
expect(editor).toHaveValue(draft);
expect(screen.queryByText("Selected choice")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Send" }));
expect(await screen.findByText("Selected choice")).toBeInTheDocument();
expect(mockFetch.mock.calls[1]?.[0]).toMatch(/\/v1\/decisions$/);
expect(JSON.parse(mockFetch.mock.calls[1]?.[1]?.body as string)).toEqual(JSON.parse(draft));
});
it("sends a native request without model so the proxy can select its default", async () => {
const user = userEvent.setup();
render(<SystemOneUI accessToken="session-key" />);
screen.getByRole("combobox", { name: "Decision endpoint" }).focus();
await user.keyboard("{ArrowDown}");
await user.click(await screen.findByRole("option", { name: "Decisions · /v1/decisions" }));
const payload = { state: "An outage", questions: { urgent: { type: "noul", instructions: "Is this urgent?" } } };
fireEvent.change(screen.getByRole("textbox", { name: "System One JSON payload" }), {
target: { value: JSON.stringify(payload) },
});
expect(screen.getByRole("button", { name: "Send" })).toBeEnabled();
await user.click(screen.getByRole("button", { name: "Send" }));
expect(await screen.findByText("jev-1.13.0")).toBeInTheDocument();
expect(mockFetch.mock.calls[0]?.[0]).toMatch(/\/v1\/decisions$/);
const body = JSON.parse(mockFetch.mock.calls[0]?.[1]?.body as string);
expect(body).toEqual(payload);
expect(body).not.toHaveProperty("model");
});
it.each(["success", "error"])("ignores a late %s after switching endpoints", async (outcome) => {
const user = userEvent.setup();
const pending = Promise.withResolvers<Response>();
mockFetch.mockReturnValueOnce(pending.promise);
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
rtlRender(
<QueryClientProvider client={queryClient}>
<SystemOneUI accessToken="session-key" />
</QueryClientProvider>,
);
await user.click(screen.getByRole("button", { name: "Send" }));
await screen.findByRole("button", { name: "Cancel request" });
screen.getByRole("combobox", { name: "Decision endpoint" }).focus();
await user.keyboard("{ArrowDown}");
await user.click(await screen.findByRole("option", { name: "Decisions · /v1/decisions" }));
expect(mockFetch.mock.calls[0]?.[1]?.signal?.aborted).toBe(true);
expect(screen.getByRole("button", { name: "Send" })).toBeEnabled();
await act(async () => {
if (outcome === "success") {
pending.resolve(createResponse({ model: "stale-model", answers: { stale: { type: "noul", noul: 1 } } }));
} else {
pending.reject(new Error("Stale request failed"));
}
await pending.promise.catch(() => undefined);
});
await waitFor(() => expect(queryClient.isMutating()).toBe(0));
expect(screen.queryByText("stale-model")).not.toBeInTheDocument();
expect(screen.queryByText("Stale request failed")).not.toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByText("100% yes")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Send" })).toBeEnabled();
await user.click(screen.getByRole("button", { name: "Send" }));
expect(await screen.findByText("jev-1.13.0")).toBeInTheDocument();
expect(screen.getByText("Selected choice")).toBeInTheDocument();
expect(mockFetch.mock.calls[1]?.[0]).toMatch(/\/v1\/decisions$/);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("uses a custom virtual key when personal key creation is disabled", async () => {
const user = userEvent.setup();
render(<SystemOneUI accessToken={null} disabledPersonalKeyCreation />);
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
fireEvent.change(screen.getByLabelText("Virtual Key", { exact: true }), {
target: { value: "test-virtual-key" },
});
await user.click(screen.getByRole("button", { name: "Send" }));
expect(await screen.findByText("Selected choice")).toBeInTheDocument();
expect(Object.values(mockFetch.mock.calls[0]?.[1]?.headers ?? {})).toContain("Bearer test-virtual-key");
});
it("renders upstream errors inline", async () => {
const user = userEvent.setup();
mockFetch.mockResolvedValueOnce(createResponse(responseBody, 401, "Virtual key rejected"));

View file

@ -8,8 +8,8 @@ import { useMutation } from "@tanstack/react-query";
import { Code, Info, LoaderCircle, RotateCcw, Send } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { makeSystemOneRequest } from "../../llm_calls/system_one";
import { SYSTEM_ONE_EXAMPLE } from "./lib/example";
import type { SystemOneRequest } from "./lib/schemas";
import { DECISIONS_EXAMPLE, SYSTEM_ONE_EXAMPLE } from "./lib/example";
import type { DecisionEndpoint, PlaygroundRequest } from "./lib/schemas";
import JsonEditor from "./JsonEditor";
import QuestionBreakdown from "./QuestionBreakdown";
import ResponseView from "./ResponseView";
@ -23,12 +23,14 @@ interface SystemOneUIProps {
type ApiKeySource = "session" | "custom";
interface SystemOneSendVariables {
payload: SystemOneRequest;
payload: PlaygroundRequest;
endpoint: DecisionEndpoint;
apiKey: string;
signal: AbortSignal;
}
const EXAMPLE_PAYLOAD = JSON.stringify(SYSTEM_ONE_EXAMPLE, null, 2);
const DECISIONS_EXAMPLE_PAYLOAD = JSON.stringify(DECISIONS_EXAMPLE, null, 2);
const DECISION_MODELS_DISCUSSION_URL = "https://github.com/BerriAI/litellm/discussions/44231";
function getCustomProxyBaseUrl(): string | undefined {
@ -38,15 +40,21 @@ function getCustomProxyBaseUrl(): string | undefined {
export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation = false }: SystemOneUIProps) {
const [apiKeySource, setApiKeySource] = useState<ApiKeySource>(disabledPersonalKeyCreation ? "custom" : "session");
const [customApiKey, setCustomApiKey] = useState("");
const [rawPayload, setRawPayload] = useState(EXAMPLE_PAYLOAD);
const [endpoint, setEndpoint] = useState<DecisionEndpoint>("/typesafe/v1/systemone");
const [payloads, setPayloads] = useState<Record<DecisionEndpoint, string>>({
"/v1/decisions": DECISIONS_EXAMPLE_PAYLOAD,
"/typesafe/v1/systemone": EXAMPLE_PAYLOAD,
});
const rawPayload = payloads[endpoint];
const examplePayload = endpoint === "/v1/decisions" ? DECISIONS_EXAMPLE_PAYLOAD : EXAMPLE_PAYLOAD;
const activeController = useRef<AbortController | null>(null);
const validation = useMemo(() => validateSystemOnePayload(rawPayload), [rawPayload]);
const validation = useMemo(() => validateSystemOnePayload(rawPayload, endpoint), [rawPayload, endpoint]);
const effectiveApiKey = apiKeySource === "session" ? accessToken || "" : customApiKey.trim();
const hasSyntaxError = validation.issues.some((issue) => issue.path === "syntax");
const systemOne = useMutation({
mutationFn: ({ payload, apiKey, signal }: SystemOneSendVariables) =>
makeSystemOneRequest(payload, apiKey, getCustomProxyBaseUrl(), signal),
mutationFn: ({ payload, apiKey, signal, endpoint }: SystemOneSendVariables) =>
makeSystemOneRequest(payload, apiKey, getCustomProxyBaseUrl(), { signal, endpoint }),
});
const isLoading = systemOne.isPending;
const { reset: resetSystemOne } = systemOne;
@ -68,7 +76,7 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
function handlePayloadChange(value: string) {
if (value !== rawPayload) {
clearRequestState();
setRawPayload(value);
setPayloads((current) => ({ ...current, [endpoint]: value }));
}
}
@ -85,12 +93,35 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
clearRequestState();
const controller = new AbortController();
activeController.current = controller;
systemOne.mutate({ payload: validation.payload, apiKey: effectiveApiKey, signal: controller.signal });
const variables = { payload: validation.payload, apiKey: effectiveApiKey, signal: controller.signal, endpoint };
systemOne.mutate(variables);
}
return (
<div className="flex h-full min-h-0 flex-col gap-4 overflow-auto p-4 xl:overflow-hidden">
<section className="grid gap-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">Endpoint</span>
<Select
value={endpoint}
onValueChange={(value) => {
if (value === "/v1/decisions" || value === "/typesafe/v1/systemone") {
clearRequestState();
setEndpoint(value);
}
}}
>
<SelectTrigger className="w-80" aria-label="Decision endpoint">
<SelectValue>
{endpoint === "/v1/decisions" ? "Decisions · /v1/decisions" : "TypeSafe · /typesafe/v1/systemone"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="/v1/decisions">Decisions · /v1/decisions</SelectItem>
<SelectItem value="/typesafe/v1/systemone">TypeSafe · /typesafe/v1/systemone</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">Virtual Key Source</span>
@ -127,8 +158,8 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
onClick={() => handlePayloadChange(EXAMPLE_PAYLOAD)}
disabled={rawPayload === EXAMPLE_PAYLOAD}
onClick={() => handlePayloadChange(examplePayload)}
disabled={rawPayload === examplePayload}
>
<RotateCcw />
Reset example
@ -148,12 +179,15 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
</Button>
</div>
</div>
<Alert role="note" aria-label="System One beta notice">
<Alert role="note" aria-label="Decision endpoint notice">
<Info />
<AlertTitle>Beta: TypeSafe Jev only for now</AlertTitle>
<AlertTitle>
{endpoint === "/v1/decisions" ? "Decision models · Jev format" : "TypeSafe Jev · System One"}
</AlertTitle>
<AlertDescription>
Sends System One requests (choice, noul, score) through /typesafe/v1/systemone and requires TYPESAFE_API_KEY
on the proxy. Support for more System One-compatible models is in progress.{" "}
{endpoint === "/v1/decisions"
? "Sends choice, noul, and score questions through /v1/decisions. Replace the example model with a decision model configured on your proxy, or omit model to use the proxy's configured default."
: "Sends requests through /typesafe/v1/systemone and requires TYPESAFE_API_KEY on the proxy."}{" "}
<a href={DECISION_MODELS_DISCUSSION_URL} target="_blank" rel="noopener noreferrer" className="underline">
Give us feedback on what you want for decision models
</a>

View file

@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { validateSystemOnePayload } from "./validatePayload";
import { systemOneResponseSchema } from "./schemas";
const request = {
model: "configured-decision-model",
state: { message: "Please help" },
questions: {
category: { type: "choice", criteria: { support: { description: "Help" }, other: null } },
urgent: { type: "noul", criteria: { true: ["An outage"], false: null } },
severity: { type: "score", instructions: { task: "Rate severity" }, criteria: [["Low"]] },
},
provider_option: { enabled: true },
};
const validate = (value: unknown) => validateSystemOnePayload(JSON.stringify(value), "/v1/decisions");
describe("native decisions validation", () => {
it("accepts structured Jev criteria, optional instructions, and provider extensions without dropping fields", () => {
expect(validate(request)).toMatchObject({ isValid: true, payload: request, issues: [] });
});
it("accepts an omitted model without inserting one", () => {
const payload = { state: request.state, questions: request.questions };
expect(validate(payload)).toMatchObject({ isValid: true, payload, issues: [] });
expect(validate(payload).payload).not.toHaveProperty("model");
});
it.each([null, "", " ", 1])("rejects an invalid explicit model (%s)", (model) => {
expect(validate({ ...request, model }).isValid).toBe(false);
});
it.each([null, 1, true])("rejects a scalar state (%s)", (state) => {
expect(validate({ ...request, state }).isValid).toBe(false);
});
it.each([
{},
{ "": request.questions.category },
Object.fromEntries(Array.from({ length: 129 }, (_, i) => [`q${i}`, request.questions.category])),
{ q: { type: "noul" } },
{ q: { type: "noul", criteria: { unexpected: "value" } } },
{ q: { type: "choice", criteria: {} } },
{ q: { type: "choice", criteria: { invalid: 1 } } },
{ q: { type: "choice", criteria: Object.fromEntries(Array.from({ length: 256 }, (_, i) => [`c${i}`, null])) } },
{ q: { type: "score", criteria: [] } },
{ q: { type: "score", criteria: Array(11).fill("level") } },
])("rejects invalid questions %#", (questions) => {
expect(validate({ ...request, questions }).isValid).toBe(false);
});
it("allows backend boundary values for score levels and question counts", () => {
expect(
validate({ ...request, questions: { q: { type: "score", criteria: Array(10).fill("level") } } }).isValid,
).toBe(true);
expect(
validate({
...request,
questions: Object.fromEntries(Array.from({ length: 128 }, (_, i) => [`q${i}`, request.questions.category])),
}).isValid,
).toBe(true);
});
it("does not loosen legacy TypeSafe request validation", () => {
expect(validateSystemOnePayload(JSON.stringify(request)).isValid).toBe(false);
});
it("parses structured score legends and nullable usage from the native endpoint", () => {
const response = {
answers: {
severity: {
type: "score",
score: 0,
confidence: 1,
probabilities: { "0": 1 },
legend: { "0": { description: "Low" } },
},
urgent: { type: "noul", noul: 0.2 },
},
usage: null,
};
expect(systemOneResponseSchema.parse(response)).toEqual(response);
});
});

View file

@ -1,6 +1,6 @@
import type { SystemOneRequest } from "./schemas";
import type { DecisionRequest, SystemOneRequest } from "./schemas";
export const SYSTEM_ONE_EXAMPLE: SystemOneRequest = {
export const SYSTEM_ONE_EXAMPLE = {
model: "jev-latest",
state:
"Since upgrading to the latest release, streaming responses stop halfway through whenever a fallback model takes over. Non-streaming requests still work. I haven't narrowed down which change caused it, but it happens on most long prompts.",
@ -35,4 +35,9 @@ export const SYSTEM_ONE_EXAMPLE: SystemOneRequest = {
],
},
},
} satisfies SystemOneRequest;
export const DECISIONS_EXAMPLE: DecisionRequest = {
...SYSTEM_ONE_EXAMPLE,
model: "your-decision-model",
};

View file

@ -1,5 +1,50 @@
import { z } from "zod";
export type DecisionEndpoint = "/v1/decisions" | "/typesafe/v1/systemone";
const decisionsJson = z.union([z.string(), z.record(z.string(), z.unknown()), z.array(z.unknown())]);
const decisionInstructions = decisionsJson.nullish();
const decisionQuestionSchema = z.discriminatedUnion("type", [
z.looseObject({
type: z.literal("choice"),
instructions: decisionInstructions,
criteria: z.record(z.string(), decisionsJson.nullable()).refine((value) => {
const count = Object.keys(value).length;
return count >= 1 && count <= 255;
}, "Choice criteria must contain between 1 and 255 options."),
}),
z
.looseObject({
type: z.literal("noul"),
instructions: decisionInstructions,
criteria: z.partialRecord(z.enum(["true", "false"]), decisionsJson.nullable()).nullish(),
})
.refine((value) => value.instructions != null || value.criteria != null, {
message: "A noul question requires instructions or criteria.",
}),
z.looseObject({
type: z.literal("score"),
instructions: decisionInstructions,
criteria: z.array(decisionsJson).min(1).max(10),
}),
]);
export const decisionsRequestSchema = z.looseObject({
model: z
.string()
.refine((value) => value.trim().length > 0, "Model must be a non-empty string.")
.optional(),
state: decisionsJson,
questions: z.record(z.string().min(1), decisionQuestionSchema).refine((value) => {
const count = Object.keys(value).length;
return count >= 1 && count <= 128;
}, "Questions must contain between 1 and 128 entries."),
});
export type DecisionRequest = z.infer<typeof decisionsRequestSchema>;
export type PlaygroundRequest = SystemOneRequest | DecisionRequest;
export type PlaygroundQuestion = SystemOneQuestion | z.infer<typeof decisionQuestionSchema>;
const MAX_CHOICE_OPTIONS = 255;
const nonEmptyString = (message: string) =>
@ -89,7 +134,7 @@ const scoreAnswerShape = {
type: z.literal("score"),
score: z.number().finite(),
confidence: probability.optional(),
legend: z.record(z.string(), z.string()).optional(),
legend: z.record(z.string(), decisionsJson).optional(),
probabilities,
};
const scoreAnswerSchema = z.looseObject(scoreAnswerShape);
@ -102,7 +147,7 @@ export const systemOneResponseSchema = z.looseObject({
z.string(),
z.discriminatedUnion("type", [noulAnswerSchema, choiceAnswerSchema, scoreAnswerSchema]),
),
usage: z.object({ input_tokens: tokenCount, output_tokens: tokenCount }).optional(),
usage: z.looseObject({ input_tokens: tokenCount, output_tokens: tokenCount }).nullish(),
});
export type SystemOneRequest = z.infer<typeof systemOneRequestSchema>;

View file

@ -1,4 +1,9 @@
import { systemOneRequestSchema, type SystemOneRequest } from "./schemas";
import {
decisionsRequestSchema,
systemOneRequestSchema,
type DecisionEndpoint,
type PlaygroundRequest,
} from "./schemas";
const RECOMMENDED_MAX_SCORE_LEVELS = 10;
@ -10,7 +15,7 @@ export interface SystemOnePayloadIssue {
export interface SystemOnePayloadValidation {
isValid: boolean;
payload?: SystemOneRequest;
payload?: PlaygroundRequest;
issues: SystemOnePayloadIssue[];
}
@ -27,7 +32,7 @@ function parseJson(raw: string): { ok: true; value: unknown } | { ok: false; mes
}
}
const scoreLevelWarnings = (payload: SystemOneRequest): SystemOnePayloadIssue[] =>
const scoreLevelWarnings = (payload: PlaygroundRequest): SystemOnePayloadIssue[] =>
Object.entries(payload.questions)
.filter(([, question]) => question.type === "score" && question.criteria.length > RECOMMENDED_MAX_SCORE_LEVELS)
.map(([id]) => ({
@ -36,7 +41,10 @@ const scoreLevelWarnings = (payload: SystemOneRequest): SystemOnePayloadIssue[]
severity: "warning",
}));
export function validateSystemOnePayload(raw: string): SystemOnePayloadValidation {
export function validateSystemOnePayload(
raw: string,
endpoint: DecisionEndpoint = "/typesafe/v1/systemone",
): SystemOnePayloadValidation {
if (!raw.trim()) {
return invalid("root", "Payload cannot be empty.");
}
@ -46,7 +54,8 @@ export function validateSystemOnePayload(raw: string): SystemOnePayloadValidatio
return invalid("syntax", `Invalid JSON syntax: ${json.message}`);
}
const result = systemOneRequestSchema.safeParse(json.value);
const schema = endpoint === "/v1/decisions" ? decisionsRequestSchema : systemOneRequestSchema;
const result = schema.safeParse(json.value);
if (!result.success) {
return {
isValid: false,

View file

@ -3,7 +3,8 @@ import { withRequiredHeaders } from "@/components/llm_calls/request_headers";
import { createApiClient } from "@/lib/http/client";
import {
systemOneResponseSchema,
type SystemOneRequest,
type DecisionEndpoint,
type PlaygroundRequest,
type SystemOneResponse,
} from "../components/systemOneUI/lib/schemas";
@ -13,10 +14,10 @@ export interface SystemOneResult {
}
export async function makeSystemOneRequest(
payload: SystemOneRequest,
payload: PlaygroundRequest,
accessToken: string,
customBaseUrl?: string,
signal?: AbortSignal,
{ signal, endpoint = "/typesafe/v1/systemone" }: { signal?: AbortSignal; endpoint?: DecisionEndpoint } = {},
): Promise<SystemOneResult> {
const proxyBaseUrl = customBaseUrl || getProxyBaseUrl();
const normalizedBaseUrl = proxyBaseUrl.endsWith("/") ? proxyBaseUrl.slice(0, -1) : proxyBaseUrl;
@ -29,7 +30,7 @@ export async function makeSystemOneRequest(
},
);
const client = createApiClient({ getBaseUrl: () => normalizedBaseUrl });
const body = await client.post<unknown>("/typesafe/v1/systemone", { body: payload, headers, signal });
const body = await client.post<unknown>(endpoint, { body: payload, headers, signal });
const parsed = systemOneResponseSchema.safeParse(body);
if (!parsed.success) {
throw new Error("System One response has an invalid shape.");