mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
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:
parent
aa4852f579
commit
6e75f28289
10 changed files with 361 additions and 41 deletions
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue