diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/ResponseView.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/ResponseView.integration.test.tsx
index 141fdc43374..f6c5794fa5c 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/ResponseView.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/ResponseView.integration.test.tsx
@@ -106,7 +106,7 @@ describe("ResponseView integration", () => {
expect(screen.getByText("99% yes")).toBeInTheDocument();
expect(screen.getByRole("meter", { name: "Yes probability" })).toHaveAttribute("aria-valuenow", "99");
expect(screen.getByRole("meter", { name: "2: angry probability" })).toBeInTheDocument();
- expect(screen.getByText("378 input / 63 output tokens")).toBeInTheDocument();
+ expect(screen.getByText("378 in / 63 out")).toBeInTheDocument();
});
it("selects the score level nearest a fractional calibrated score", () => {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/ResponseView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/ResponseView.tsx
index 538d2f659a6..cc73b44265c 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/ResponseView.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/ResponseView.tsx
@@ -1,9 +1,9 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
-import { ChevronDown, LoaderCircle } from "lucide-react";
+import { ChevronRight, Inbox, LoaderCircle } from "lucide-react";
import { useState } from "react";
import type { SystemOneAnswer, SystemOneResponse } from "./lib/schemas";
import type { OpenAIDecisionsResponse } from "./lib/openAIDecisions";
@@ -175,17 +175,19 @@ function AnswerCards({ answers }: { answers: SystemOneResponse["answers"] | Open
type: answer.type,
details:
,
}));
- return cards.map(({ id, name, type, details }) => (
-
-
- {name}
-
- {type}
-
-
- {details}
-
- ));
+ return (
+
+ {cards.map(({ id, name, type, details }) => (
+
+
+
{name}
+ {type}
+
+ {details}
+
+ ))}
+
+ );
}
export default function ResponseView({
@@ -220,11 +222,16 @@ export default function ResponseView({
if (!response) {
return (
-
-
- Calibrated probabilities
- Send a request to see decision answers and probabilities.
+
+
+ Response
+
+
+
+
+ Send a request to see answers here
+
);
}
@@ -232,36 +239,43 @@ export default function ResponseView({
const model = response.model ?? fallbackModel;
return (
-
-
-
-
- {view === "curl" ? "Response JSON" : "Calibrated probabilities"}
- {model && {model} }
-
-
- {latencyMs !== undefined && {Math.round(latencyMs)} ms }
- {response.usage && (
-
- {response.usage.input_tokens} input / {response.usage.output_tokens} output tokens
-
- )}
-
+
+
+ {view === "curl" ? "Response JSON" : "Response"}
+
+ {latencyMs !== undefined && (
+
+ {Math.round(latencyMs)} ms
+
+ )}
+ {model && (
+
+ {model}
+
+ )}
+ {response.usage && (
+
+ {response.usage.input_tokens} in / {response.usage.output_tokens} out
+
+ )}
-
+
{view === "curl" ? : }
{view === "form" && (
-
+
-
+
+
Raw response
}
/>
-
+
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneForm.tsx
index a241e68cb9e..1e3ae9e07fe 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneForm.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneForm.tsx
@@ -6,7 +6,7 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useId, useMemo, useState } from "react";
-import { AddButton, FormFrame, RemoveButton } from "./DecisionFormControls";
+import { AddButton, FormFrame, QuestionCardHeader, RemoveButton } from "./DecisionFormControls";
import {
blankQuestion,
freeName,
@@ -70,7 +70,7 @@ function KeyInput({ value, taken, onCommit, label, placeholder }: KeyInputProps)
const errorId = useId();
const [draft, setDraft] = useState(value);
const next = draft.trim();
- const error = keyError(next, value, taken);
+ const error = next === value ? undefined : keyError(next, value, taken);
function commit() {
if (error === undefined && next !== value) {
@@ -203,34 +203,49 @@ function CriteriaEditor({ question, onChange }: QuestionEditorProps {
name: string;
+ index: number;
taken: readonly string[];
onRename: (name: string) => void;
onRemove: () => void;
}
-function QuestionCard({ name, question, taken, onChange, onRename, onRemove }: QuestionCardProps) {
+function QuestionCard({ name, index, question, taken, onChange, onRename, onRemove }: QuestionCardProps) {
const instructionsId = useId();
+ const title = name === "" ? String(index + 1) : name;
return (
-
-
-
-
isQuestionType(type) && onChange(retype(question, type))}
- >
-
- {TYPE_LABELS[question.type]}
-
-
- {QUESTION_TYPES.map((type) => (
-
- {TYPE_LABELS[type]}
-
- ))}
-
-
-
+
+
+
+
+ Name
+
+
+
+ Answer type
+ isQuestionType(type) && onChange(retype(question, type))}
+ >
+
+ {TYPE_LABELS[question.type]}
+
+
+ {QUESTION_TYPES.map((type) => (
+
+ {TYPE_LABELS[type]}
+
+ ))}
+
+
+
Instructions
@@ -286,6 +301,7 @@ export default function SystemOneForm({ value, onChange, validation, onReset }:
writeQuestions({ ...questions, [name]: next })}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneUI.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneUI.integration.test.tsx
index 8158e5782bb..680fea187c9 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneUI.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneUI.integration.test.tsx
@@ -75,9 +75,13 @@ const selectEndpoint = async (endpoint: "/v1/decisions" | "/v1/systemone") => {
};
const selectPreset = async (label: string) => {
- screen.getByRole("combobox", { name: "Example" }).focus();
- await userEvent.keyboard("{ArrowDown}");
- await userEvent.click(await screen.findByRole("option", { name: label }));
+ await userEvent.click(screen.getByRole("button", { name: "Load example" }));
+ await userEvent.click(await screen.findByRole("menuitem", { name: label }));
+};
+
+const renderPopulated = async (ui: ReactElement) => {
+ render(ui);
+ await selectPreset("Triage a bug report");
};
const createResponse = (body: SystemOneResponse | OpenAIDecisionsResponse, status = 200, errorText = "") =>
@@ -108,36 +112,38 @@ describe("SystemOneUI integration", () => {
document.cookie = "token=; path=/; max-age=0";
});
- it("switches between example presets and reads Custom once the request is edited", async () => {
+ it("starts empty with one blank question and loads examples from the menu", async () => {
render( );
- const input = screen.getByRole("textbox", { name: "Input" }) as HTMLTextAreaElement;
- const example = input.value;
- const picker = screen.getByRole("combobox", { name: "Example" });
- expect(picker).toHaveTextContent("Triage a bug report");
+ expect(screen.getByRole("textbox", { name: "Input" })).toHaveValue("");
+ expect(screen.getAllByRole("group", { name: /Question / })).toHaveLength(1);
+ expect(screen.getByRole("button", { name: "Clear form" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
- fireEvent.change(input, { target: { value: "Something else" } });
- expect(picker).toHaveTextContent("Custom");
await selectPreset("Triage a bug report");
- expect(screen.getByRole("textbox", { name: "Input" })).toHaveValue(example);
- expect(picker).toHaveTextContent("Triage a bug report");
+ const input = screen.getByRole("textbox", { name: "Input" }) as HTMLTextAreaElement;
+ expect(input).toHaveDisplayValue(/streaming responses/);
+ expect(screen.getByRole("button", { name: "Clear form" })).toBeEnabled();
await selectPreset("Score a sales lead");
- expect(picker).toHaveTextContent("Score a sales lead");
expect(await readCurlPayload()).toMatchObject({ questions: { fit: { type: "score" } } });
});
it("clears the whole request and lets a preset be picked from nothing", async () => {
const user = userEvent.setup();
- render( );
- const clear = screen.getByRole("button", { name: "Clear" });
+ await renderPopulated( );
+ const clear = screen.getByRole("button", { name: "Clear form" });
expect(screen.getByRole("textbox", { name: "Input" })).toHaveDisplayValue(/streaming responses/);
await user.click(clear);
expect(screen.getByRole("textbox", { name: "Input" })).toHaveValue("");
- expect(screen.queryAllByRole("group", { name: /Question / })).toHaveLength(0);
+ expect(screen.getAllByRole("group", { name: /Question / })).toHaveLength(1);
+ expect(screen.getByRole("textbox", { name: "Question name" })).toHaveValue("");
+ expect(screen.queryByText("Enter a name")).not.toBeInTheDocument();
+ expect(screen.getByRole("textbox", { name: "Instructions" })).toHaveValue("");
+ expect(screen.queryByRole("textbox", { name: "Option label" })).not.toBeInTheDocument();
+ expect(screen.getByText("1 issue to fix")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(clear).toBeDisabled();
- expect(screen.getByRole("combobox", { name: "Example" })).toHaveTextContent("Custom");
await selectPreset("Route a support ticket");
expect(screen.getByRole("textbox", { name: "Input" })).toHaveDisplayValue(/charged twice/);
@@ -148,26 +154,28 @@ describe("SystemOneUI integration", () => {
it("keeps the preset label after picking a model and keeps that model when clearing", async () => {
const user = userEvent.setup();
mockModelLookup.mockResolvedValue(modelGroupInfoResponse(DECISION_AND_CHAT_MODELS));
- render( );
+ await renderPopulated( );
const modelPicker = screen.getByRole("combobox", { name: "Decision model" });
await waitFor(() => expect(modelPicker).toHaveValue("jev-latest"));
- const picker = screen.getByRole("combobox", { name: "Example" });
await user.clear(modelPicker);
await user.type(modelPicker, "p");
await user.click(await screen.findByRole("option", { name: "pplx-decider" }));
- expect(picker).toHaveTextContent("Triage a bug report");
expect(await readCurlPayload()).toMatchObject({ model: "pplx-decider" });
- await user.click(screen.getByRole("button", { name: "Clear" }));
- expect(await readCurlPayload()).toEqual({ model: "pplx-decider", state: "", questions: {} });
+ await user.click(screen.getByRole("button", { name: "Clear form" }));
+ expect(await readCurlPayload()).toEqual({
+ model: "pplx-decider",
+ state: "",
+ questions: { "": { type: "choice", instructions: "", criteria: {} } },
+ });
await selectPreset("Score a sales lead");
expect(await readCurlPayload()).toMatchObject({ model: "pplx-decider", questions: { fit: { type: "score" } } });
});
it("offers the same presets on /v1/decisions in the OpenAI shape and clears to an empty input", async () => {
const user = userEvent.setup();
- render( );
+ await renderPopulated( );
await selectEndpoint("/v1/decisions");
await selectPreset("Moderate a forum comment");
const payload = await readCurlPayload();
@@ -176,13 +184,17 @@ describe("SystemOneUI integration", () => {
{ type: "predicate", name: "breaks_rules", instructions: "Does this comment break the community guidelines?" },
]);
- await user.click(screen.getByRole("button", { name: "Clear" }));
- expect(await readCurlPayload()).toEqual({ input: "", questions: [] });
+ await user.click(screen.getByRole("button", { name: "Clear form" }));
+ expect(await readCurlPayload()).toEqual({
+ input: "",
+ questions: [{ type: "predicate", name: "", instructions: "" }],
+ });
+ expect(screen.getByRole("textbox", { name: "Question name" })).toHaveValue("");
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
});
it("offers both literal proxy endpoints without TypeSafe or product prefixes", async () => {
- render( );
+ await renderPopulated( );
expect(screen.getByRole("combobox", { name: "Decision endpoint" })).toHaveTextContent("/v1/systemone");
screen.getByRole("combobox", { name: "Decision endpoint" }).focus();
@@ -204,7 +216,7 @@ describe("SystemOneUI integration", () => {
it("opens on /v1/systemone with the first decision model the key can call", async () => {
const user = userEvent.setup();
mockModelLookup.mockResolvedValue(modelGroupInfoResponse(DECISION_AND_CHAT_MODELS));
- render( );
+ await renderPopulated( );
await waitFor(() => expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveValue("jev-latest"));
expect(lookupAuthHeaders(mockModelLookup.mock.calls)).toEqual([expect.arrayContaining(["Bearer session-key"])]);
@@ -222,12 +234,13 @@ describe("SystemOneUI integration", () => {
it("sends each endpoint's real shape and switches existing responses between cards and JSON without resending", async () => {
const user = userEvent.setup();
mockModelLookup.mockResolvedValue(modelGroupInfoResponse(DECISION_AND_CHAT_MODELS));
- render( );
+ await renderPopulated( );
await waitFor(() => expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveValue("jev-latest"));
fireEvent.change(screen.getByRole("textbox", { name: "Input" }), { target: { value: "An outage" } });
await selectEndpoint("/v1/decisions");
expect(screen.getByRole("textbox", { name: "Input" })).not.toHaveValue("An outage");
+ await selectPreset("Triage a bug report");
expect(await readCurlPayload()).toMatchObject({
input: expect.any(String),
questions: expect.arrayContaining([expect.objectContaining({ type: "predicate" })]),
@@ -261,7 +274,9 @@ describe("SystemOneUI integration", () => {
await selectEndpoint("/v1/systemone");
expect(screen.getByRole("textbox", { name: "Input" })).toHaveValue("An outage");
- expect(screen.queryByRole("region", { name: "Decisions results" })).not.toBeInTheDocument();
+ expect(
+ within(screen.getByRole("region", { name: "Decisions results" })).getByText("Send a request to see answers here"),
+ ).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Send" }));
expect(await screen.findByText("Selected choice")).toBeInTheDocument();
expect(screen.getByRole("meter", { name: "technical probability" })).toHaveAttribute("aria-valuenow", "90");
@@ -284,9 +299,9 @@ describe("SystemOneUI integration", () => {
});
it("validates /v1/decisions independently and preserves an invalid draft across switches", async () => {
- render( );
+ await renderPopulated( );
await selectEndpoint("/v1/decisions");
- await userEvent.click(screen.getByRole("button", { name: "Clear" }));
+ await userEvent.click(screen.getByRole("button", { name: "Clear form" }));
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
await selectEndpoint("/v1/systemone");
expect(screen.getByRole("button", { name: "Send" })).toBeEnabled();
@@ -305,8 +320,9 @@ describe("SystemOneUI integration", () => {
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
}),
);
- render( );
+ await renderPopulated( );
await selectEndpoint("/v1/decisions");
+ await selectPreset("Triage a bug report");
expect(screen.getByRole("tab", { name: "Form" })).toHaveAttribute("aria-selected", "true");
fireEvent.change(screen.getByRole("textbox", { name: "Input" }), { target: { value: "Login is broken" } });
const firstQuestion = within(screen.getByRole("group", { name: "Question 1" }));
@@ -341,8 +357,9 @@ describe("SystemOneUI integration", () => {
it("adds and removes Decisions questions, boolean choices and score levels without stale fields", async () => {
const user = userEvent.setup();
- render( );
+ await renderPopulated( );
await selectEndpoint("/v1/decisions");
+ await selectPreset("Triage a bug report");
const question = (index: number) => within(screen.getByRole("group", { name: `Question ${index}` }));
const pick = async (control: HTMLElement, label: string) => {
control.focus();
@@ -410,6 +427,7 @@ describe("SystemOneUI integration", () => {
,
);
+ await selectPreset("Triage a bug report");
await userEvent.click(screen.getByRole("button", { name: "Send" }));
await screen.findByRole("button", { name: "Cancel request" });
await selectEndpoint("/v1/decisions");
@@ -426,14 +444,15 @@ describe("SystemOneUI integration", () => {
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByText("stale-model")).not.toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Form" })).toHaveAttribute("aria-selected", "true");
- expect(screen.getByRole("button", { name: "Send" })).toBeEnabled();
+ expect(screen.queryByRole("button", { name: "Cancel request" })).not.toBeInTheDocument();
});
it("offers only decision models and writes the picked one into the request", async () => {
const user = userEvent.setup();
mockModelLookup.mockResolvedValue(modelGroupInfoResponse(DECISION_AND_CHAT_MODELS));
- render( );
- const picker = await screen.findByRole("combobox", { name: "Decision model" });
+ await renderPopulated( );
+
+ const picker = screen.getByRole("combobox", { name: "Decision model" });
await user.clear(picker);
await user.type(picker, "p");
@@ -456,7 +475,7 @@ describe("SystemOneUI integration", () => {
mockModelLookup.mockResolvedValue(
modelGroupInfoResponse([{ model_group: "typesafe/jev-latest", mode: "evaluation" }]),
);
- render( );
+ await renderPopulated( );
await waitFor(() =>
expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveValue("typesafe/jev-latest"),
);
@@ -470,7 +489,7 @@ describe("SystemOneUI integration", () => {
});
it("links to Add Model when the key cannot call any decision model", async () => {
- render( );
+ await renderPopulated( );
expect(await screen.findByRole("link", { name: "Add a decision model" })).toHaveAttribute(
"href",
@@ -496,7 +515,7 @@ describe("SystemOneUI integration", () => {
it("keeps the admin signed in when a typed virtual key has expired", async () => {
document.cookie = "token=ui-session; path=/";
mockModelLookup.mockResolvedValue(EXPIRED_KEY_RESPONSE);
- render( );
+ await renderPopulated( );
fireEvent.change(screen.getByLabelText("Virtual Key", { exact: true }), { target: { value: "sk-expired" } });
@@ -511,7 +530,7 @@ describe("SystemOneUI integration", () => {
it("keeps an edited request in place when decision models load afterwards", async () => {
const models = Promise.withResolvers();
mockModelLookup.mockReturnValue(models.promise);
- render( );
+ await renderPopulated( );
fireEvent.change(screen.getByRole("textbox", { name: "Input" }), { target: { value: "edited" } });
await act(async () => {
@@ -535,9 +554,10 @@ describe("SystemOneUI integration", () => {
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
}),
);
- render( );
+ await renderPopulated( );
if (endpoint === "/v1/decisions") {
await selectEndpoint(endpoint);
+ await selectPreset("Triage a bug report");
}
await userEvent.click(screen.getByRole("button", { name: "Send" }));
expect(await screen.findByText("jev-latest")).toBeInTheDocument();
@@ -554,7 +574,7 @@ describe("SystemOneUI integration", () => {
it("shows a read-only cURL command for the proxy with a key placeholder instead of the real key", async () => {
const user = userEvent.setup();
sessionStorage.setItem("customProxyBaseUrl", "https://proxy.example/");
- render( );
+ await renderPopulated( );
await user.click(screen.getByRole("tab", { name: "cURL" }));
const command = screen.getByLabelText("cURL request");
@@ -568,35 +588,68 @@ describe("SystemOneUI integration", () => {
expect(screen.getByRole("button", { name: "Copy cURL" })).toBeInTheDocument();
await selectEndpoint("/v1/decisions");
+ await selectPreset("Triage a bug report");
expect(screen.getByLabelText("cURL request")).toHaveTextContent("https://proxy.example/v1/decisions");
expect(screen.getByRole("button", { name: "Send" })).toBeEnabled();
});
it("keeps Send below the form and shows results only after a request", async () => {
const user = userEvent.setup();
- render( );
+ await renderPopulated( );
const send = screen.getByRole("button", { name: "Send" });
const editor = screen.getByRole("region", { name: "Decisions request editor" });
const form = within(editor).getByRole("textbox", { name: "Input" });
expect(form.compareDocumentPosition(send) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
- expect(screen.queryByRole("region", { name: "Decisions results" })).not.toBeInTheDocument();
+ expect(
+ within(screen.getByRole("region", { name: "Decisions results" })).getByText("Send a request to see answers here"),
+ ).toBeInTheDocument();
expect(screen.queryByRole("note")).not.toBeInTheDocument();
await user.click(send);
const results = await screen.findByRole("region", { name: "Decisions results" });
- expect(within(results).getByText("Calibrated probabilities")).toBeInTheDocument();
+ expect(within(results).getByText("Response")).toBeInTheDocument();
expect(editor.compareDocumentPosition(results) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
fireEvent.change(within(editor).getByRole("textbox", { name: "Input" }), { target: { value: "Edited state" } });
- expect(screen.queryByRole("region", { name: "Decisions results" })).not.toBeInTheDocument();
+ expect(
+ within(screen.getByRole("region", { name: "Decisions results" })).getByText("Send a request to see answers here"),
+ ).toBeInTheDocument();
+ });
+
+ it("ties each setup label to its control, numbers question cards and keeps the actions in the editor footer", async () => {
+ await renderPopulated( );
+
+ expect(screen.getByText("Endpoint")).toHaveAttribute("for", screen.getByLabelText("Decision endpoint").id);
+ expect(screen.getByText("Model")).toHaveAttribute("for", screen.getByLabelText("Decision model").id);
+ expect(screen.getByText("API key")).toHaveAttribute("for", screen.getByLabelText("Virtual Key Source").id);
+
+ const question = screen.getByRole("group", { name: "Question area" });
+ expect(within(question).getByText("Question 1")).toBeInTheDocument();
+ expect(within(question).getByText("Name")).toBeInTheDocument();
+ expect(within(question).getByText("Answer type")).toBeInTheDocument();
+
+ const editor = screen.getByRole("region", { name: "Decisions request editor" });
+ const card = within(editor).getByRole("group", { name: "Request editor" });
+ const actions = within(card).getByRole("toolbar", { name: "Request actions" });
+ const status = within(actions).getByRole("status");
+ const send = within(actions).getByRole("button", { name: "Send" });
+ expect(status).toHaveTextContent("Ready to send");
+ expect(status.compareDocumentPosition(send) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(within(actions).queryByRole("button", { name: "Clear form" })).not.toBeInTheDocument();
+ const clear = within(card).getByRole("button", { name: "Clear form" });
+ expect(clear.compareDocumentPosition(actions) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(
+ within(card).getByRole("button", { name: "Load example" }).compareDocumentPosition(clear) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
});
it("sends to /v1/systemone even without discovered models and renders calibrated answers", async () => {
const user = userEvent.setup();
sessionStorage.setItem("customProxyBaseUrl", "https://stale.example.com/");
- render( );
+ await renderPopulated( );
sessionStorage.setItem("customProxyBaseUrl", "https://tenant.example.com/");
await user.click(screen.getByRole("button", { name: "Send" }));
@@ -622,7 +675,7 @@ describe("SystemOneUI integration", () => {
answers: responseBody.answers,
}),
);
- render( );
+ await renderPopulated( );
await waitFor(() => expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveValue("jev-latest"));
@@ -634,7 +687,7 @@ describe("SystemOneUI integration", () => {
it("sends a native request without model so the proxy can select its default", async () => {
const user = userEvent.setup();
- render( );
+ await renderPopulated( );
fireEvent.change(screen.getByRole("textbox", { name: "Input" }), { target: { value: "An outage" } });
expect(screen.getByRole("button", { name: "Send" })).toBeEnabled();
await user.click(screen.getByRole("button", { name: "Send" }));
@@ -655,6 +708,7 @@ describe("SystemOneUI integration", () => {
,
);
+ await selectPreset("Triage a bug report");
await user.click(screen.getByRole("button", { name: "Send" }));
await screen.findByRole("button", { name: "Cancel request" });
fireEvent.change(screen.getByRole("textbox", { name: "Input" }), { target: { value: "A new request" } });
@@ -685,7 +739,7 @@ describe("SystemOneUI integration", () => {
it("uses a custom virtual key when personal key creation is disabled", async () => {
const user = userEvent.setup();
- render( );
+ await renderPopulated( );
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
fireEvent.change(screen.getByLabelText("Virtual Key", { exact: true }), {
target: { value: "test-virtual-key" },
@@ -698,7 +752,7 @@ describe("SystemOneUI integration", () => {
it("renders upstream errors inline", async () => {
const user = userEvent.setup();
mockFetch.mockResolvedValueOnce(createResponse(responseBody, 401, "Virtual key rejected"));
- render( );
+ await renderPopulated( );
await user.click(screen.getByRole("button", { name: "Send" }));
@@ -721,7 +775,7 @@ describe("SystemOneUI integration", () => {
},
}),
} as Response);
- render( );
+ await renderPopulated( );
await user.click(screen.getByRole("button", { name: "Send" }));
@@ -731,7 +785,7 @@ describe("SystemOneUI integration", () => {
it("clears a previous answer when the request editor changes", async () => {
const user = userEvent.setup();
- render( );
+ await renderPopulated( );
await user.click(screen.getByRole("button", { name: "Send" }));
expect(await screen.findByText("Selected choice")).toBeInTheDocument();
@@ -752,6 +806,8 @@ describe("SystemOneUI integration", () => {
,
);
+ await selectPreset("Triage a bug report");
+ await selectPreset("Triage a bug report");
await user.click(screen.getByRole("button", { name: "Send" }));
expect(await screen.findByText("Selected choice")).toBeInTheDocument();
@@ -775,7 +831,7 @@ describe("SystemOneUI integration", () => {
});
}),
);
- render( );
+ await renderPopulated( );
await user.click(screen.getByRole("button", { name: "Send" }));
await user.click(await screen.findByRole("button", { name: "Cancel request" }));
@@ -803,7 +859,7 @@ describe("SystemOneUI integration", () => {
it("explains the yes / no answer type option", async () => {
const user = userEvent.setup();
- render( );
+ await renderPopulated( );
question("has_repro_steps").getByRole("combobox", { name: "Answer type" }).focus();
await user.keyboard("{ArrowDown}");
@@ -816,7 +872,7 @@ describe("SystemOneUI integration", () => {
it("sends a choice and a yes/no question built in the form without opening JSON", async () => {
const user = userEvent.setup();
- render( );
+ await renderPopulated( );
const input = screen.getByRole("textbox", { name: "Input" });
fill(input, "The login page is blank after the upgrade");
for (const name of ["area", "has_repro_steps", "severity"]) {
@@ -826,6 +882,8 @@ describe("SystemOneUI integration", () => {
await user.click(screen.getByRole("button", { name: "Add question" }));
rename(question("question_1").getByRole("textbox", { name: "Question name" }), "team");
fill(question("team").getByRole("textbox", { name: "Instructions" }), "Which team owns this?");
+ await user.click(question("team").getByRole("button", { name: "Add option" }));
+ await user.click(question("team").getByRole("button", { name: "Add option" }));
const [first, second] = question("team").getAllByRole("textbox", { name: "Option label" });
rename(first, "frontend");
rename(second, "backend");
@@ -862,7 +920,7 @@ describe("SystemOneUI integration", () => {
it("edits score levels and keeps instructions when a question changes type", async () => {
const user = userEvent.setup();
mockModelLookup.mockResolvedValue(modelGroupInfoResponse(DECISION_AND_CHAT_MODELS));
- render( );
+ await renderPopulated( );
await waitFor(() => expect(screen.getByRole("combobox", { name: "Decision model" })).toHaveValue("jev-latest"));
const level = question("severity").getByRole("textbox", { name: "Level 0" });
@@ -889,14 +947,14 @@ describe("SystemOneUI integration", () => {
expect(sentBody().questions.has_repro_steps).toEqual({
type: "choice",
instructions: "Does the issue include steps someone could follow to reproduce it?",
- criteria: { option_1: "", option_2: "", option_3: "" },
+ criteria: { option_1: "" },
});
expect(Object.keys(sentBody().questions.area.criteria)).toEqual(["backend", "sdk", "ui"]);
});
it("refuses an empty or taken question name and trims a new one", async () => {
const user = userEvent.setup();
- render( );
+ await renderPopulated( );
const name = question("severity").getByRole("textbox", { name: "Question name" });
expect(name).toHaveAttribute("aria-invalid", "false");
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneUI.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneUI.tsx
index 5ece80abaf3..43d9d4f3024 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneUI.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/SystemOneUI.tsx
@@ -3,6 +3,12 @@
import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SimpleTooltip } from "@/components/ui/tooltip";
import { SearchSelect } from "@/components/shared/SearchSelect";
@@ -11,13 +17,14 @@ import { DECISIONS_DOCS_URL } from "@/lib/decisionModels";
import { getGlobalLitellmHeaderName, getProxyBaseUrl } from "@/components/networking";
import { uiHref } from "@/utils/uiHref";
import { useMutation } from "@tanstack/react-query";
-import { BookOpen, Eraser, LoaderCircle, Send } from "lucide-react";
-import { useEffect, useMemo, useRef, useState } from "react";
+import { BookOpen, ChevronDown, Eraser, FileText, LoaderCircle, Send } from "lucide-react";
+import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { makePlaygroundDecisionRequest, type PlaygroundDecisionRequest } from "../../llm_calls/system_one";
-import { DECISION_PRESETS, emptyPayload, matchPreset, presetPayload, type DecisionPreset } from "./lib/example";
+import { DECISION_PRESETS, emptyPayload, presetPayload } from "./lib/example";
import { payloadModel, withPayloadModel } from "./lib/payloadModel";
import CurlPreview from "./CurlPreview";
import ResponseView from "./ResponseView";
+import { ValidationSummary } from "./ValidationFeedback";
import SystemOneForm from "./SystemOneForm";
import OpenAIDecisionsForm from "./OpenAIDecisionsForm";
import { validateSystemOnePayload, validateOpenAIDecisionsPayload, errorCount } from "./lib/validatePayload";
@@ -36,32 +43,33 @@ interface SystemOneSendVariables {
signal: AbortSignal;
}
-const CUSTOM_PRESET = "custom";
-
-interface PresetPickerProps {
- activePreset: DecisionPreset | undefined;
- onPick: (id: string | null) => void;
+function SetupField({ label, htmlFor, children }: { label: string; htmlFor: string; children: ReactNode }) {
+ return (
+
+
+ {label}
+
+ {children}
+
+ );
}
-function PresetPicker({ activePreset, onPick }: PresetPickerProps) {
+function ExamplesMenu({ onPick }: { onPick: (id: string) => void }) {
return (
-
-
- {activePreset?.label ?? "Custom"}
-
-
- {activePreset === undefined && (
-
- Custom
-
- )}
+
+
+
+ Load example
+
+
+
{DECISION_PRESETS.map((preset) => (
-
+ onPick(preset.id)}>
{preset.label}
-
+
))}
-
-
+
+
);
}
@@ -81,9 +89,8 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
const [endpoint, setEndpoint] = useState("/v1/systemone");
const [drafts, setDrafts] = useState>>({});
const [view, setView] = useState("form");
- const rawPayload = drafts[endpoint] ?? presetPayload(DECISION_PRESETS[0], endpoint, decisionModels[0]);
+ const rawPayload = drafts[endpoint] ?? emptyPayload(endpoint, decisionModels[0]);
const draftModel = drafts[endpoint] === undefined ? decisionModels[0] : payloadModel(rawPayload);
- const activePreset = matchPreset(endpoint, rawPayload);
const clearedPayload = emptyPayload(endpoint, draftModel);
const activeController = useRef(null);
const validated = useMemo(
@@ -100,7 +107,6 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
makePlaygroundDecisionRequest(request, apiKey, getCustomProxyBaseUrl(), { signal }),
});
const isLoading = systemOne.isPending;
- const hasResult = isLoading || systemOne.data !== undefined || systemOne.error !== null;
const { reset: resetSystemOne } = systemOne;
useEffect(() => () => activeController.current?.abort(), []);
@@ -128,7 +134,7 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
}
}
- function handlePresetPick(id: string | null) {
+ function handlePresetPick(id: string) {
const preset = DECISION_PRESETS.find((candidate) => candidate.id === id);
if (preset !== undefined) {
handlePayloadChange(presetPayload(preset, endpoint, draftModel));
@@ -160,33 +166,31 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
return (
-
-
-
-
{
- if (value === "/v1/decisions" || value === "/v1/systemone") {
- clearRequestState();
- setEndpoint(value);
- }
- }}
- >
-
- {endpoint}
-
-
- /v1/decisions
- /v1/systemone
-
-
-
+
(value === "form" || value === "curl") && setView(value)}
- className="min-h-0 flex-1"
+ className={cn(
+ "flex min-h-0 flex-1 flex-col gap-0 overflow-hidden rounded-lg border bg-card",
+ !validation.isValid && "border-destructive/60",
+ )}
>
-
+
Form
cURL
-
-
+
+
+
+ handlePayloadChange(clearedPayload)}
+ disabled={rawPayload === clearedPayload || isLoading}
+ >
+
+
+
-
+
{validated.endpoint === "/v1/decisions" ? (
)}
-
+
-
-
-
-
handlePayloadChange(clearedPayload)}
- disabled={rawPayload === clearedPayload}
+
-
- Clear
-
-
- {isLoading && (
-
- Cancel request
+
+
+ {isLoading && (
+
+ Cancel request
+
+ )}
+
+ {isLoading ? : }
+ Send
- )}
-
- {isLoading ? : }
- Send
-
+
-
+
- {hasResult && (
-
- )}
+
);
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/ValidationFeedback.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/ValidationFeedback.test.tsx
index 24f93c58af9..a2f6c24781a 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/ValidationFeedback.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/ValidationFeedback.test.tsx
@@ -1,6 +1,6 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
-import { IssueList, ValidationStatus } from "./ValidationFeedback";
+import { IssueList, ValidationStatus, ValidationSummary } from "./ValidationFeedback";
import type { PayloadValidation } from "./lib/validatePayload";
const validation = (issues: PayloadValidation
["issues"]): PayloadValidation => ({
@@ -29,12 +29,28 @@ describe("ValidationStatus", () => {
});
describe("IssueList", () => {
- it("lists every issue with its path and says ready when there are none", () => {
+ it("lists every issue under its field label and renders nothing when there are none", () => {
const { rerender } = render(
,
);
- expect(screen.getByRole("list", { name: "Payload validation issues" })).toHaveTextContent("stateRequired");
+ expect(screen.getByRole("list", { name: "Payload validation issues" })).toHaveTextContent("InputRequired");
rerender( );
- expect(screen.getByRole("status")).toHaveTextContent("Ready to send");
+ expect(screen.queryByRole("list", { name: "Payload validation issues" })).not.toBeInTheDocument();
+ });
+});
+
+describe("ValidationSummary", () => {
+ it("says ready when nothing blocks the request and counts the errors otherwise", () => {
+ const { rerender } = render( );
+ expect(screen.getByRole("status")).toHaveTextContent("Ready to send");
+ rerender(
+ ,
+ );
+ expect(screen.getByRole("status")).toHaveTextContent("1 issue to fix");
});
});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/ValidationFeedback.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/ValidationFeedback.tsx
index 369c4243857..e8152456d15 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/ValidationFeedback.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/ValidationFeedback.tsx
@@ -1,5 +1,6 @@
import { Badge } from "@/components/ui/badge";
import { CircleAlert, CircleCheck, TriangleAlert } from "lucide-react";
+import { issueLabel } from "./lib/issueLabel";
import { errorCount as countErrors, type PayloadValidation } from "./lib/validatePayload";
export function ValidationStatus({ validation }: { validation: PayloadValidation }) {
@@ -14,14 +15,25 @@ export function ValidationStatus({ validation }: { validation: PayloadValidation
return Valid payload ;
}
+export function ValidationSummary({ validation }: { validation: PayloadValidation }) {
+ const errorCount = countErrors(validation);
+ return (
+
+ {errorCount === 0 ? (
+
+ ) : (
+
+ )}
+
+ {errorCount === 0 ? "Ready to send" : `${errorCount} ${errorCount === 1 ? "issue" : "issues"} to fix`}
+
+
+ );
+}
+
export function IssueList({ id, validation }: { id: string; validation: PayloadValidation }) {
if (validation.issues.length === 0) {
- return (
-
-
- Ready to send
-
- );
+ return null;
}
return (
@@ -32,7 +44,7 @@ export function IssueList({ id, validation }: { id: string; validation: PayloadV
) : (
)}
- {issue.path}
+ {issueLabel(issue.path)}
{issue.message}
))}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/example.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/example.test.ts
index e6b10fda545..e24cf8895b3 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/example.test.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/example.test.ts
@@ -54,7 +54,10 @@ describe("decision presets", () => {
it("omits the model from payloads when none is known", () => {
expect(JSON.parse(presetPayload(DECISION_PRESETS[1], "/v1/systemone"))).not.toHaveProperty("model");
- expect(JSON.parse(emptyPayload("/v1/decisions"))).toEqual({ input: "", questions: [] });
+ expect(JSON.parse(emptyPayload("/v1/decisions"))).toEqual({
+ input: "",
+ questions: [{ type: "predicate", name: "", instructions: "" }],
+ });
expect(validateSystemOnePayload(emptyPayload("/v1/systemone"), "/v1/systemone").isValid).toBe(false);
});
});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/example.ts b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/example.ts
index c8cbeaf2e5c..226d47f4a3b 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/example.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/example.ts
@@ -1,6 +1,7 @@
import type { DecisionRequest, SystemOneRequest } from "./schemas";
import type { OpenAIDecisionsRequest } from "./openAIDecisions";
import { parseJson } from "./validatePayload";
+import { blankQuestion } from "./formPayload";
type PresetChoice = {
type: "choice";
@@ -192,7 +193,9 @@ export const matchPreset = (endpoint: PresetEndpoint, raw: string): DecisionPres
export const emptyPayload = (endpoint: PresetEndpoint, model?: string): string =>
JSON.stringify(
- endpoint === "/v1/decisions" ? { model, input: "", questions: [] } : { model, state: "", questions: {} },
+ endpoint === "/v1/decisions"
+ ? { model, input: "", questions: [{ type: "predicate", name: "", instructions: "" }] }
+ : { model, state: "", questions: { "": blankQuestion() } },
null,
2,
);
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/formPayload.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/formPayload.test.ts
index 8339abeadab..ef52876ef52 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/formPayload.test.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/formPayload.test.ts
@@ -82,7 +82,7 @@ describe("retype", () => {
type: "choice",
instructions: "Is it urgent?",
note: "kept",
- criteria: { option_1: "", option_2: "" },
+ criteria: {},
};
const asScore = { type: "score", instructions: "Is it urgent?", note: "kept", criteria: ["", ""] };
expect(retype(noul, "choice")).toEqual(asChoice);
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/formPayload.ts b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/formPayload.ts
index 754476b8ff1..1906ad80cd9 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/formPayload.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/formPayload.ts
@@ -64,7 +64,7 @@ export const removeAt = (items: readonly T[], index: number): T[] =>
export const blankQuestion = (): ChoiceQuestion => ({
type: "choice",
instructions: "",
- criteria: { option_1: "", option_2: "" },
+ criteria: {},
});
export const retype = (question: FormQuestion, type: QuestionType): FormQuestion => {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/issueLabel.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/issueLabel.test.ts
new file mode 100644
index 00000000000..29ae0f60ae4
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/issueLabel.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+import { issueLabel } from "./issueLabel";
+
+describe("issueLabel", () => {
+ it("names the Input field for both endpoints instead of the JSON key", () => {
+ expect(issueLabel("state")).toBe("Input");
+ expect(issueLabel("input")).toBe("Input");
+ });
+
+ it("names top-level fields and the request itself", () => {
+ expect(issueLabel("model")).toBe("Model");
+ expect(issueLabel("questions")).toBe("Questions");
+ expect(issueLabel("safety_identifier")).toBe("Safety identifier");
+ expect(issueLabel("root")).toBe("Request");
+ expect(issueLabel("syntax")).toBe("Request");
+ });
+
+ it("names a question by its key or its 1-based position and the field inside it", () => {
+ expect(issueLabel("questions.urgency")).toBe('Question "urgency"');
+ expect(issueLabel("questions.urgency.criteria")).toBe('Question "urgency" options');
+ expect(issueLabel("questions.urgency.criteria.1")).toBe('Question "urgency" options');
+ expect(issueLabel("questions.category.instructions")).toBe('Question "category" instructions');
+ expect(issueLabel("questions.0.name")).toBe("Question 1 name");
+ expect(issueLabel("questions.2.type")).toBe("Question 3 answer type");
+ });
+
+ it("falls back to the humanized key for anything unknown", () => {
+ expect(issueLabel("max_tokens")).toBe("max tokens");
+ expect(issueLabel("questions.a.max_items")).toBe('Question "a" max items');
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/issueLabel.ts b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/issueLabel.ts
new file mode 100644
index 00000000000..76acc266117
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/issueLabel.ts
@@ -0,0 +1,30 @@
+const TOP_LEVEL: Record = {
+ state: "Input",
+ input: "Input",
+ model: "Model",
+ questions: "Questions",
+ safety_identifier: "Safety identifier",
+ root: "Request",
+ syntax: "Request",
+};
+
+const QUESTION_FIELD: Record = {
+ name: "name",
+ type: "answer type",
+ instructions: "instructions",
+ criteria: "options",
+};
+
+const humanize = (segment: string): string => segment.replace(/_/g, " ");
+
+export function issueLabel(path: string): string {
+ const [head, question, field] = path.split(".");
+ if (head !== "questions" || question === undefined) {
+ return TOP_LEVEL[head] ?? humanize(head);
+ }
+ const title = /^\d+$/.test(question) ? `Question ${Number(question) + 1}` : `Question "${question}"`;
+ if (field === undefined) {
+ return title;
+ }
+ return `${title} ${QUESTION_FIELD[field] ?? humanize(field)}`;
+}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/validatePayload.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/validatePayload.test.ts
index 603c22c0830..6e91f09f8bd 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/validatePayload.test.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/validatePayload.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { SYSTEM_ONE_EXAMPLE } from "./example";
-import { validateSystemOnePayload } from "./validatePayload";
+import { validateOpenAIDecisionsPayload, validateSystemOnePayload } from "./validatePayload";
const requestWith = (fields: Record = {}) =>
JSON.stringify({
@@ -331,3 +331,45 @@ describe("validateSystemOnePayload", () => {
expect(validateSystemOnePayload(JSON.stringify(SYSTEM_ONE_EXAMPLE))).toMatchObject({ isValid: true, issues: [] });
});
});
+
+describe("blank context", () => {
+ const question = { type: "choice", instructions: "Pick one", criteria: { a: "", b: "" } };
+
+ it.each(["", " "])("rejects /v1/systemone with state %j even when the schema passes", (state) => {
+ const result = validateSystemOnePayload(JSON.stringify({ state, questions: { q: question } }), "/v1/systemone");
+ expect(result).toMatchObject({ isValid: false, payload: expect.anything() });
+ expect(result.issues.map((issue) => issue.path)).toEqual(["state"]);
+ });
+
+ it("reports only the missing state while the blank question is still invalid", () => {
+ const raw = JSON.stringify({ state: "", questions: { "": { type: "choice", instructions: "", criteria: {} } } });
+ expect(validateSystemOnePayload(raw, "/v1/systemone").issues.map((issue) => issue.path)).toEqual(["state"]);
+ const filled = validateSystemOnePayload(raw.replace('"state":""', '"state":"A ticket"'), "/v1/systemone");
+ expect(filled.isValid).toBe(false);
+ expect(filled.issues).toContainEqual({
+ path: "questions",
+ message: "Give every question a name.",
+ severity: "error",
+ });
+ });
+
+ it("accepts /v1/systemone once the state carries text", () => {
+ const raw = JSON.stringify({ state: "A customer wrote in", questions: { q: question } });
+ expect(validateSystemOnePayload(raw, "/v1/systemone")).toMatchObject({ isValid: true, issues: [] });
+ });
+
+ it.each(["", " ", []])("rejects /v1/decisions with %j input even when the schema passes", (input) => {
+ const raw = JSON.stringify({ input, questions: [{ type: "predicate", name: "q", instructions: "Is it?" }] });
+ const result = validateOpenAIDecisionsPayload(raw);
+ expect(result.isValid).toBe(false);
+ expect(result.issues.map((issue) => issue.path)).toEqual(["input"]);
+ });
+
+ it("accepts /v1/decisions once the input carries text", () => {
+ const raw = JSON.stringify({
+ input: "Ship it?",
+ questions: [{ type: "predicate", name: "q", instructions: "Is it?" }],
+ });
+ expect(validateOpenAIDecisionsPayload(raw)).toMatchObject({ isValid: true, issues: [] });
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/validatePayload.ts b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/validatePayload.ts
index d74923bb3e6..1fb32efd158 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/validatePayload.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/systemOneUI/lib/validatePayload.ts
@@ -26,6 +26,11 @@ export type SystemOnePayloadValidation = PayloadValidation;
export const errorCount = (validation: PayloadValidation): number =>
validation.issues.filter((issue) => issue.severity === "error").length;
+const toIssue = (path: PropertyKey[], message: string): SystemOnePayloadIssue =>
+ path[0] === "questions" && path[1] === ""
+ ? { path: "questions", message: "Give every question a name.", severity: "error" }
+ : { path: path.length > 0 ? path.join(".") : "root", message, severity: "error" };
+
const invalid = (path: string, message: string) => ({
isValid: false,
issues: [{ path, message, severity: "error" as const }],
@@ -62,26 +67,47 @@ function validatePayload(raw: string, schema: z.ZodType): PayloadValidatio
if (!result.success) {
return {
isValid: false,
- issues: result.error.issues.map((issue) => ({
- path: issue.path.length > 0 ? issue.path.join(".") : "root",
- message: issue.message,
- severity: "error",
- })),
+ issues: result.error.issues.map((issue) => toIssue(issue.path, issue.message)),
};
}
return { isValid: true, payload: result.data, issues: [] };
}
+const isBlankContext = (context: unknown): boolean =>
+ (typeof context === "string" && context.trim() === "") || (Array.isArray(context) && context.length === 0);
+
+const isMissingContext = (raw: string, path: "state" | "input"): boolean => {
+ const json = parseJson(raw);
+ if (!json.ok || typeof json.value !== "object" || json.value === null) {
+ return false;
+ }
+ return isBlankContext((json.value as Record)[path]);
+};
+
+const requireContext = (raw: string, path: "state" | "input", result: PayloadValidation): PayloadValidation =>
+ isMissingContext(raw, path)
+ ? {
+ ...result,
+ isValid: false,
+ issues: [{ path, message: "Add the text the model should decide about.", severity: "error" }],
+ }
+ : result;
+
export function validateSystemOnePayload(
raw: string,
endpoint: DecisionEndpoint = "/typesafe/v1/systemone",
): SystemOnePayloadValidation {
const schema = endpoint === "/v1/systemone" ? decisionsRequestSchema : systemOneRequestSchema;
const result = validatePayload(raw, schema);
- return result.payload ? { ...result, issues: scoreLevelWarnings(result.payload) } : result;
+ return requireContext(
+ raw,
+ "state",
+ result.payload ? { ...result, issues: scoreLevelWarnings(result.payload) } : result,
+ );
}
export function validateOpenAIDecisionsPayload(raw: string): PayloadValidation {
- return validatePayload(raw, openAIDecisionsRequestSchema);
+ const result = validatePayload(raw, openAIDecisionsRequestSchema);
+ return requireContext(raw, "input", result);
}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx
index bb5612e7367..fa5f76317de 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx
@@ -7,7 +7,6 @@ import CompareUI from "@/app/(dashboard)/playground/components/compareUI/Compare
import ComplianceUI from "@/app/(dashboard)/playground/components/complianceUI/ComplianceUI";
import SystemOneUI from "@/app/(dashboard)/playground/components/systemOneUI/SystemOneUI";
import BetaBadge from "@/components/BetaBadge";
-import { DeprecationBanner } from "@/components/DeprecationBanner";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { fetchProxySettings } from "@/utils/proxyUtils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -100,7 +99,6 @@ export default function PlaygroundPage() {
-
-
-
- >
- );
+ return ;
}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/TelemetryEnterpriseDialog.tsx b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/TelemetryEnterpriseDialog.tsx
new file mode 100644
index 00000000000..11b9fc0b302
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/TelemetryEnterpriseDialog.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+
+export function TelemetryEnterpriseDialog({
+ open,
+ onOpenChange,
+ premiumUser,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ premiumUser: boolean;
+}) {
+ return (
+ onOpenChange(next)}>
+
+
+
+ {premiumUser ? "Contact your enterprise representative" : "Telemetry is an enterprise-only feature"}
+
+
+ {premiumUser
+ ? "Telemetry can't be turned on from this page. Reach out to your LiteLLM enterprise representative to turn it on for this proxy."
+ : "Telemetry is only available on LiteLLM Enterprise, and this proxy isn't on an enterprise license."}
+
+
+
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/TelemetryEnvBanner.tsx b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/TelemetryEnvBanner.tsx
new file mode 100644
index 00000000000..c94a1e30e5c
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/TelemetryEnvBanner.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import type { TelemetrySettings } from "@/app/(dashboard)/hooks/telemetry/useTelemetrySettings";
+import { useTelemetrySettings } from "@/app/(dashboard)/hooks/telemetry/useTelemetrySettings";
+
+export function TelemetryEnvNotice({ settings }: { settings: TelemetrySettings }) {
+ if (settings.environment_variables.length === 0) return null;
+ return (
+
+
+ {settings.vetoed
+ ? "Telemetry is turned off by an environment variable"
+ : "Telemetry environment variables are set"}
+
+
+ Set on this proxy: {settings.environment_variables.join(", ")}.{" "}
+ {settings.set_by_environment
+ ? "These override the settings on this page, which are read-only until they are removed."
+ : "The groups below can still be changed here."}
+
+
+ );
+}
+
+export default function TelemetryEnvBanner() {
+ const { data } = useTelemetrySettings();
+ if (data === undefined) return null;
+ return (
+
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/TelemetrySettingsPage.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/TelemetrySettingsPage.integration.test.tsx
new file mode 100644
index 00000000000..2a42cd4b1fd
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/TelemetrySettingsPage.integration.test.tsx
@@ -0,0 +1,151 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { NuqsTestingAdapter } from "nuqs/adapters/testing";
+import { backgroundFetchClient, fetchClient } from "@/lib/http/api";
+import type { TelemetrySettings } from "@/app/(dashboard)/hooks/telemetry/useTelemetrySettings";
+import TelemetrySettingsPage from "./TelemetrySettingsPage";
+
+vi.mock("@/lib/telemetry/uiEvents", async (original) => ({
+ ...(await original()),
+ recordUiEvent: vi.fn(() => Promise.resolve()),
+}));
+vi.mock("next/navigation", () => ({ usePathname: () => "/ui/telemetry" }));
+const auth = vi.hoisted(() => ({ premiumUser: false }));
+vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
+ default: () => ({ accessToken: "sk-test", userRole: "proxy_admin", premiumUser: auth.premiumUser }),
+}));
+
+const SETTINGS = {
+ destination: "local_table",
+ stored_destination: "local_table",
+ destination_set_by_environment: false,
+ available_destinations: ["local_table"],
+ editable: true,
+ environment_variables: [],
+ flush_interval_seconds: 300,
+ report: null,
+ report_is_sample: true,
+ retention_days: 30,
+ groups: [
+ { group: "heartbeat", enabled: true, requires: null },
+ { group: "request_success", enabled: true, requires: "heartbeat" },
+ { group: "page_navigation", enabled: false, requires: "heartbeat" },
+ ],
+} as unknown as TelemetrySettings;
+
+const OFF = {
+ ...SETTINGS,
+ groups: SETTINGS.groups.map((info) => ({ ...info, enabled: false })),
+} as TelemetrySettings;
+
+const renderPage = () =>
+ render(
+
+
+
+
+ ,
+ );
+
+describe("TelemetrySettingsPage", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ auth.premiumUser = false;
+ });
+
+ it("says nothing is recorded without a destination and gates picking one behind the enterprise modal", async () => {
+ const unchosen = { ...SETTINGS, destination: null, stored_destination: null };
+ vi.spyOn(backgroundFetchClient, "GET").mockResolvedValue({ data: unchosen, response: new Response() } as never);
+ const put = vi.spyOn(fetchClient, "PUT").mockReturnValue(new Promise(() => undefined) as never);
+ const user = userEvent.setup();
+ renderPage();
+
+ expect(
+ await screen.findByText("Telemetry is on but no destination is chosen, so nothing is recorded"),
+ ).toBeVisible();
+ expect(screen.getByRole("radio", { name: /^HTTP/ })).toHaveAttribute("aria-disabled", "true");
+ await user.click(screen.getByRole("radio", { name: /^Local table/ }));
+
+ expect(await screen.findByRole("dialog", { name: "Telemetry is an enterprise-only feature" })).toBeVisible();
+ expect(put).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ [false, "Telemetry is an enterprise-only feature"],
+ [true, "Contact your enterprise representative"],
+ ])("never turns telemetry on from the top switch (enterprise: %s)", async (premiumUser, title) => {
+ auth.premiumUser = premiumUser;
+ vi.spyOn(backgroundFetchClient, "GET").mockResolvedValue({ data: OFF, response: new Response() } as never);
+ const put = vi.spyOn(fetchClient, "PUT").mockReturnValue(new Promise(() => undefined) as never);
+ const user = userEvent.setup();
+ renderPage();
+
+ const telemetry = await screen.findByRole("switch", { name: "Telemetry" });
+ expect(telemetry).not.toBeChecked();
+ expect(screen.getByRole("switch", { name: "Heartbeat" })).toHaveAttribute("aria-disabled", "true");
+ expect(screen.getByRole("radio", { name: /^Local table/ })).toHaveAttribute("aria-disabled", "true");
+ await user.click(telemetry);
+
+ expect(await screen.findByRole("dialog", { name: title })).toBeVisible();
+ expect(put).not.toHaveBeenCalled();
+ expect(telemetry).not.toBeChecked();
+ });
+
+ it("turns every group off from the top switch and keeps the saved destination", async () => {
+ vi.spyOn(backgroundFetchClient, "GET").mockResolvedValue({ data: SETTINGS, response: new Response() } as never);
+ const put = vi.spyOn(fetchClient, "PUT").mockReturnValue(new Promise(() => undefined) as never);
+ const user = userEvent.setup();
+ renderPage();
+
+ await user.click(await screen.findByRole("switch", { name: "Telemetry" }));
+
+ expect(put).toHaveBeenCalledWith("/telemetry/settings", { body: { groups: [], destination: "local_table" } });
+ });
+
+ it("gates turning another group on behind the enterprise modal while telemetry is on", async () => {
+ vi.spyOn(backgroundFetchClient, "GET").mockResolvedValue({ data: SETTINGS, response: new Response() } as never);
+ const put = vi.spyOn(fetchClient, "PUT").mockReturnValue(new Promise(() => undefined) as never);
+ const user = userEvent.setup();
+ renderPage();
+
+ await user.click(await screen.findByRole("tab", { name: "Admin UI" }));
+ await user.click(await screen.findByRole("switch", { name: "Page navigation" }));
+
+ expect(await screen.findByRole("dialog", { name: "Telemetry is an enterprise-only feature" })).toBeVisible();
+ expect(put).not.toHaveBeenCalled();
+ });
+
+ it("keeps the saved destination when a group is switched", async () => {
+ vi.spyOn(backgroundFetchClient, "GET").mockResolvedValue({ data: SETTINGS, response: new Response() } as never);
+ const put = vi.spyOn(fetchClient, "PUT").mockReturnValue(new Promise(() => undefined) as never);
+ const user = userEvent.setup();
+ renderPage();
+
+ await user.click(await screen.findByRole("switch", { name: "Request success" }));
+
+ expect(put).toHaveBeenCalledWith("/telemetry/settings", {
+ body: { groups: ["heartbeat"], destination: "local_table" },
+ });
+ });
+
+ it("locks the other tab's switches while a save from one tab is still in flight", async () => {
+ vi.spyOn(backgroundFetchClient, "GET").mockResolvedValue({ data: SETTINGS, response: new Response() } as never);
+ const put = vi.spyOn(fetchClient, "PUT").mockReturnValue(new Promise(() => undefined) as never);
+ const user = userEvent.setup();
+ render(
+
+
+
+
+ ,
+ );
+
+ await user.click(await screen.findByRole("switch", { name: "Heartbeat" }));
+ await user.click(screen.getByRole("tab", { name: "Admin UI" }));
+
+ expect(await screen.findByRole("switch", { name: "Page navigation" })).toHaveAttribute("aria-disabled", "true");
+ expect(put).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/TelemetrySettingsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/TelemetrySettingsPage.tsx
new file mode 100644
index 00000000000..b99bce385f3
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/TelemetrySettingsPage.tsx
@@ -0,0 +1,253 @@
+"use client";
+
+import { useState } from "react";
+import { Switch } from "@/components/ui/switch";
+import { FieldLabel } from "@/components/ui/field";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Activity } from "lucide-react";
+import { Page, PageTabs, PageTabsContent, PageTabsList, PageTabsTrigger } from "@/components/shared/Page";
+import { PageHeader, PageHeaderControls, PageHeaderDescription, PageHeaderTitle } from "@/components/shared/PageHeader";
+import { useUrlTab } from "@/hooks/useUrlTab";
+import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
+import {
+ type TelemetryDestination,
+ type TelemetryGroup,
+ type TelemetrySettings,
+ useTelemetrySettings,
+ useUpdateTelemetrySettings,
+} from "@/app/(dashboard)/hooks/telemetry/useTelemetrySettings";
+import { GROUP_COPY, PROXY_GROUPS, UI_GROUPS, relativeDepths, toggleGroup, type Requires } from "./telemetryGroups";
+import { TelemetryEnterpriseDialog } from "./TelemetryEnterpriseDialog";
+
+const TABS = ["proxy", "ui"] as const;
+
+const destinationText = (settings: TelemetrySettings): string => {
+ const every = `every ${settings.flush_interval_seconds} s`;
+ switch (settings.destination) {
+ case "http":
+ return `One JSON report per worker ${every} and on shutdown, by HTTP POST to LITELLM_TELEMETRY_ENDPOINT. A failed send is retried next window`;
+ case "local_table":
+ return `One report per worker ${every} and on shutdown, stored in this proxy's LiteLLM_TelemetryReport table for ${settings.retention_days} days. Nothing leaves the proxy. Admins can export from GET /telemetry/reports`;
+ case null:
+ return "No destination is chosen, so nothing is recorded";
+ }
+};
+
+const DESTINATION_LABELS: Record = {
+ local_table: "Local table (LiteLLM_TelemetryReport in this proxy's database)",
+ http: "HTTP (POST to LITELLM_TELEMETRY_ENDPOINT)",
+};
+
+type SettingsUpdate = ReturnType;
+
+const enabledGroups = (settings: TelemetrySettings): Set =>
+ new Set(settings.groups.filter((info) => info.enabled).map((info) => info.group));
+
+const isOn = (settings: TelemetrySettings): boolean => enabledGroups(settings).size > 0;
+
+const savedDestination = (settings: TelemetrySettings): TelemetryDestination | null =>
+ settings.destination_set_by_environment ? null : settings.stored_destination ?? null;
+
+function DestinationPicker({
+ settings,
+ update,
+ requestEnable,
+}: {
+ settings: TelemetrySettings;
+ update: SettingsUpdate;
+ requestEnable: () => void;
+}) {
+ const unchangeable = !settings.editable || settings.destination_set_by_environment;
+ const locked = unchangeable || update.isPending || !isOn(settings);
+ const missing = settings.destination === null && enabledGroups(settings).size > 0;
+ return (
+
+ Destination
+ {missing && (
+ Telemetry is on but no destination is chosen, so nothing is recorded
+ )}
+
+ settings.destination === null
+ ? requestEnable()
+ : update.mutate({ groups: [...enabledGroups(settings)], destination: value as TelemetryDestination })
+ }
+ >
+ {(Object.keys(DESTINATION_LABELS) as TelemetryDestination[]).map((destination) => (
+
+
+ {DESTINATION_LABELS[destination]}
+
+ ))}
+
+ {settings.destination_set_by_environment && (
+ Set by LITELLM_TELEMETRY_DESTINATION
+ )}
+
+ );
+}
+
+function GroupList({
+ groups,
+ settings,
+ update,
+ requestEnable,
+}: {
+ groups: readonly TelemetryGroup[];
+ settings: TelemetrySettings;
+ update: SettingsUpdate;
+ requestEnable: () => void;
+}) {
+ const requires: Requires = new Map(settings.groups.map((info) => [info.group, info.requires ?? null]));
+ const enabled = enabledGroups(settings);
+ const depths = relativeDepths(groups, requires);
+ const onToggle = (group: TelemetryGroup, on: boolean) => {
+ if (on) {
+ requestEnable();
+ return;
+ }
+ update.mutate({ groups: [...toggleGroup(group, on, enabled, requires)], destination: savedDestination(settings) });
+ };
+ return (
+
+ {groups.map((group) => {
+ const copy = GROUP_COPY[group];
+ const locked = !settings.editable || update.isPending || !isOn(settings);
+ const depth = depths.get(group) ?? 0;
+ return (
+
+ onToggle(group, on)}
+ />
+
+
{copy.title}
+ {copy.slices !== undefined &&
Slices by: {copy.slices}
}
+
+ {copy.slices !== undefined ? "Counts" : "Sends"}: {copy.counts}
+
+
Helps catch: {copy.helps}
+
+
+ );
+ })}
+ {update.error !== null && {update.error.message} }
+
+ );
+}
+
+function TelemetrySwitch({
+ settings,
+ update,
+ requestEnable,
+}: {
+ settings: TelemetrySettings;
+ update: SettingsUpdate;
+ requestEnable: () => void;
+}) {
+ const on = isOn(settings);
+ return (
+
+
+ next ? requestEnable() : update.mutate({ groups: [], destination: savedDestination(settings) })
+ }
+ />
+ {on ? "On" : "Off"}
+
+ );
+}
+
+function ReportPreview({ settings }: { settings: TelemetrySettings }) {
+ return (
+
+
+ {settings.report_is_sample ? "Sample report" : "Last report sent from this worker"}
+
+
+ {settings.report_is_sample
+ ? "Made-up traffic through the real filter, with the groups that are on (all groups while telemetry is off)"
+ : "Exactly what this worker last sent or stored"}
+
+
+ {JSON.stringify(settings.report, null, 2)}
+
+
+ );
+}
+
+export default function TelemetrySettingsPage() {
+ const [tab, setTab] = useUrlTab(TABS, "proxy");
+ const { data: settings, isLoading, error } = useTelemetrySettings();
+ const update = useUpdateTelemetrySettings();
+ const { premiumUser } = useAuthorized();
+ const [enableDialogOpen, setEnableDialogOpen] = useState(false);
+ const requestEnable = () => setEnableDialogOpen(true);
+ if (isLoading) return Loading telemetry settings... ;
+ if (settings === undefined)
+ return Could not load telemetry settings: {error?.message} ;
+ return (
+
+ setTab(value as (typeof TABS)[number])}>
+
+
+
+ Telemetry
+
+
+ Choose what privacy-safe, aggregated usage data this proxy shares
+
+
+
+
+ Proxy requests
+ Admin UI
+
+
+
+
+
+
+
+
+
+ Sent only while Page navigation is on, to this proxy at POST /telemetry/ui_events, and counted into the same
+ report as proxy traffic
+
+
+
+
+
+ How and when it is sent
+ {destinationText(settings)}. Changes apply from the next window
+
+ Off by default. Each switch adds aggregated counts, never prompts, responses, keys, ids or header values.
+ LITELLM_TELEMETRY_DISABLED=true turns everything off
+
+
+
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/telemetryGroups.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/telemetryGroups.test.ts
new file mode 100644
index 00000000000..063e13b92e0
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/telemetryGroups.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from "vitest";
+import type { TelemetryGroup } from "@/app/(dashboard)/hooks/telemetry/useTelemetrySettings";
+import { depthOf, relativeDepths, toggleGroup, type Requires } from "./telemetryGroups";
+
+const REQUIRES: Requires = new Map([
+ ["heartbeat", null],
+ ["request_success", "heartbeat"],
+ ["token_info", "request_success"],
+ ["request_taxonomy", "request_success"],
+ ["event_details", "request_taxonomy"],
+ ["instance_configuration", "heartbeat"],
+ ["page_navigation", "heartbeat"],
+]);
+
+const ALL = new Set(REQUIRES.keys());
+
+describe("toggleGroup", () => {
+ it("turning heartbeat off turns every other group off", () => {
+ expect(toggleGroup("heartbeat", false, ALL, REQUIRES)).toEqual(new Set());
+ });
+
+ it("turning a middle group off keeps its parents and siblings and drops its descendants", () => {
+ expect(toggleGroup("request_taxonomy", false, ALL, REQUIRES)).toEqual(
+ new Set(["heartbeat", "request_success", "token_info", "instance_configuration", "page_navigation"]),
+ );
+ });
+
+ it("token info and request taxonomy are set independently of each other", () => {
+ const success = new Set(["heartbeat", "request_success"]);
+ expect(toggleGroup("request_taxonomy", true, success, REQUIRES)).toEqual(
+ new Set(["heartbeat", "request_success", "request_taxonomy"]),
+ );
+ expect(toggleGroup("token_info", false, ALL, REQUIRES)).toEqual(
+ new Set([...ALL].filter((g) => g !== "token_info")),
+ );
+ });
+
+ it("turning a nested group on also turns on every group it builds on", () => {
+ expect(toggleGroup("event_details", true, new Set(), REQUIRES)).toEqual(
+ new Set(["heartbeat", "request_success", "request_taxonomy", "event_details"]),
+ );
+ expect(toggleGroup("token_info", true, new Set(["heartbeat", "instance_configuration"]), REQUIRES)).toEqual(
+ new Set(["heartbeat", "instance_configuration", "request_success", "token_info"]),
+ );
+ });
+});
+
+describe("depthOf", () => {
+ it("counts the groups above each one so siblings share an indent", () => {
+ expect(
+ (["heartbeat", "request_success", "token_info", "request_taxonomy", "event_details"] as const).map((g) =>
+ depthOf(g, REQUIRES),
+ ),
+ ).toEqual([0, 1, 2, 2, 3]);
+ });
+});
+
+describe("relativeDepths", () => {
+ it("indents from the shallowest group shown, whatever its absolute depth", () => {
+ expect(relativeDepths(["page_navigation"], REQUIRES)).toEqual(new Map([["page_navigation", 0]]));
+ expect(relativeDepths(["request_taxonomy", "event_details", "token_info"], REQUIRES)).toEqual(
+ new Map([
+ ["request_taxonomy", 0],
+ ["event_details", 1],
+ ["token_info", 0],
+ ]),
+ );
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/telemetryGroups.ts b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/telemetryGroups.ts
new file mode 100644
index 00000000000..3ed450d8aaf
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/telemetryGroups.ts
@@ -0,0 +1,97 @@
+import type { TelemetryGroup } from "@/app/(dashboard)/hooks/telemetry/useTelemetrySettings";
+
+export interface GroupCopy {
+ readonly title: string;
+ readonly slices?: string;
+ readonly counts: string;
+ readonly helps: string;
+}
+
+export const PROXY_GROUPS: readonly TelemetryGroup[] = [
+ "heartbeat",
+ "request_success",
+ "token_info",
+ "request_taxonomy",
+ "event_details",
+ "instance_configuration",
+];
+
+export const UI_GROUPS: readonly TelemetryGroup[] = ["page_navigation"];
+
+export const GROUP_COPY: Readonly> = {
+ heartbeat: {
+ title: "Heartbeat",
+ counts: "Random instance id, LiteLLM version, report window, which groups are on",
+ helps: "Which versions are running",
+ },
+ request_success: {
+ title: "Request success",
+ slices: "endpoint, LiteLLM status, provider status, stream, handled by Rust, LiteLLM cache hit",
+ counts: "requests, provider attempts, latency to headers and to first byte",
+ helps: "Error-rate and latency regressions, Rust vs Python",
+ },
+ token_info: {
+ title: "Token info",
+ slices: "provider prompt-cache hit",
+ counts: "input, output and cache-read token sums",
+ helps: "Broken prompt caching or token counting",
+ },
+ request_taxonomy: {
+ title: "Request taxonomy",
+ slices: "provider, salted deployment hash",
+ counts: "one row per provider attempt with its status",
+ helps: "Which provider is failing, retries and fallbacks",
+ },
+ event_details: {
+ title: "Event details",
+ counts: "message block counts and types, allowlisted header names (no values), provider time to first token",
+ helps: "Which request shapes and clients fail",
+ },
+ instance_configuration: {
+ title: "Instance configuration",
+ counts: "names of allowlisted config keys that are set (no values)",
+ helps: "Which features an upgrade must keep working",
+ },
+ page_navigation: {
+ title: "Page navigation",
+ counts: "Admin UI page views and tab switches by route name (no ids or typed text)",
+ helps: "Which pages and tabs get used",
+ },
+};
+
+export type Requires = ReadonlyMap;
+
+const dependents = (group: TelemetryGroup, requires: Requires): readonly TelemetryGroup[] =>
+ [...requires.entries()]
+ .filter(([, parent]) => parent === group)
+ .flatMap(([child]) => [child, ...dependents(child, requires)]);
+
+export const depthOf = (group: TelemetryGroup, requires: Requires): number => {
+ const parent = requires.get(group) ?? null;
+ return parent === null ? 0 : 1 + depthOf(parent, requires);
+};
+
+export const relativeDepths = (
+ groups: readonly TelemetryGroup[],
+ requires: Requires,
+): ReadonlyMap => {
+ const depths = groups.map((group) => [group, depthOf(group, requires)] as const);
+ const shallowest = Math.min(...depths.map(([, depth]) => depth));
+ return new Map(depths.map(([group, depth]) => [group, depth - shallowest]));
+};
+
+const withAncestors = (group: TelemetryGroup, requires: Requires): readonly TelemetryGroup[] => {
+ const parent = requires.get(group) ?? null;
+ return parent === null ? [group] : [group, ...withAncestors(parent, requires)];
+};
+
+export const toggleGroup = (
+ group: TelemetryGroup,
+ on: boolean,
+ enabled: ReadonlySet,
+ requires: Requires,
+): ReadonlySet => {
+ if (on) return new Set([...enabled, ...withAncestors(group, requires)]);
+ const removed = new Set([group, ...dependents(group, requires)]);
+ return new Set([...enabled].filter((g) => !removed.has(g)));
+};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/telemetry/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/page.tsx
new file mode 100644
index 00000000000..0ca7bee9388
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/page.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import TelemetrySettingsPage from "./_components/TelemetrySettingsPage";
+
+export default function Telemetry() {
+ return ;
+}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/workflows/page.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/workflows/page.integration.test.tsx
index 5281ba626e1..6aa7119b034 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/workflows/page.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/workflows/page.integration.test.tsx
@@ -50,9 +50,9 @@ describe("Workflows page access by role", () => {
},
);
- it("hides the deprecation banner along with the page body for a denied role", () => {
- renderAs("Internal User");
+ it("shows an admin the deprecation notice", () => {
+ renderAs("Admin");
- expect(screen.queryByText(/draft deprecation list/i)).not.toBeInTheDocument();
+ expect(screen.getByRole("alert")).toHaveTextContent("Workflows");
});
});
diff --git a/ui/litellm-dashboard/src/components/DeprecationBanner.test.tsx b/ui/litellm-dashboard/src/components/DeprecationBanner.test.tsx
index 596ad2a1626..ead6785bfd4 100644
--- a/ui/litellm-dashboard/src/components/DeprecationBanner.test.tsx
+++ b/ui/litellm-dashboard/src/components/DeprecationBanner.test.tsx
@@ -1,44 +1,20 @@
import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { DeprecationBanner } from "./DeprecationBanner";
describe("DeprecationBanner", () => {
- it("names the deprecated feature in the heading and the body", () => {
- render( );
+ it("names the deprecated feature", () => {
+ render( );
- expect(screen.getByText("Memory is on a draft deprecation list")).toBeInTheDocument();
- expect(screen.getByText(/Memory is one of several experimental features/)).toBeInTheDocument();
- });
-
- it("states the target removal date and that the list is not final", () => {
- render( );
-
- expect(screen.getByText(/as early as September 1, 2026/)).toBeInTheDocument();
- expect(screen.getByText(/This list is a draft and is not final/)).toBeInTheDocument();
+ expect(screen.getByRole("alert")).toHaveTextContent("Workflows");
});
it("links to the deprecation discussion in a new tab without leaking the opener", () => {
- render( );
+ render( );
const link = screen.getByRole("link", { name: "deprecation discussion" });
expect(link).toHaveAttribute("href", "https://github.com/BerriAI/litellm/discussions/32090");
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
-
- it("exposes a named close control", () => {
- render( );
-
- expect(screen.getByRole("button", { name: /close/i })).toBeInTheDocument();
- });
-
- it("hides the banner once the close control is used", async () => {
- const user = userEvent.setup();
- render( );
-
- await user.click(screen.getByRole("button", { name: /close/i }));
-
- expect(screen.queryByText("Memory is on a draft deprecation list")).not.toBeInTheDocument();
- });
});
diff --git a/ui/litellm-dashboard/src/components/DeprecationBanner.tsx b/ui/litellm-dashboard/src/components/DeprecationBanner.tsx
index 9df34636f82..75541e4087c 100644
--- a/ui/litellm-dashboard/src/components/DeprecationBanner.tsx
+++ b/ui/litellm-dashboard/src/components/DeprecationBanner.tsx
@@ -1,52 +1,26 @@
"use client";
-import React, { useState } from "react";
+import React from "react";
import Link from "next/link";
-import { Info, X } from "lucide-react";
+import { Info } from "lucide-react";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
const DEPRECATION_DISCUSSION_URL = "https://github.com/BerriAI/litellm/discussions/32090";
-const DEPRECATION_TARGET_DATE = "September 1, 2026";
interface DeprecationBannerProps {
featureName: string;
}
-export const DeprecationBanner: React.FC = ({ featureName }) => {
- const [isClosed, setIsClosed] = useState(false);
-
- if (isClosed) {
- return null;
- }
-
- return (
-
-
-
-
{`${featureName} is on a draft deprecation list`}
-
- {`${featureName} is one of several experimental features we're considering removing, potentially as early as ${DEPRECATION_TARGET_DATE}. This list is a draft and is not final. If you rely on this feature, please share feedback on the `}
-
- deprecation discussion
-
- .
-
-
-
setIsClosed(true)}
- className="shrink-0 rounded-md p-0.5 text-muted-foreground transition-colors hover:text-foreground"
- >
-
-
-
- );
-};
+export const DeprecationBanner: React.FC = ({ featureName }) => (
+
+
+ {`${featureName} is deprecated`}
+
+ {`${featureName} will be removed in the near future. See the `}
+
+ deprecation discussion
+
+ {" for details."}
+
+
+);
diff --git a/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.test.tsx b/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.test.tsx
index c5dda3d3f7c..8cc98e986db 100644
--- a/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.test.tsx
+++ b/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.test.tsx
@@ -45,7 +45,7 @@ describe("ThemeToggle", () => {
it("names the mode the click will switch to, so the button says what it does", async () => {
renderToggle();
- expect(screen.getByRole("button", { name: "Switch to dark mode (beta)" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Switch to dark mode" })).toBeInTheDocument();
await userEvent.click(toggle());
@@ -59,13 +59,4 @@ describe("ThemeToggle", () => {
expect(localStorage.getItem("theme")).toBe("system");
});
-
- it("keeps the beta marker out of the toolbar label once dark is on", async () => {
- renderToggle();
-
- await userEvent.click(toggle());
-
- expect(toggle()).not.toHaveTextContent("Beta");
- expect(toggle()).toHaveAccessibleName("Switch to light mode");
- });
});
diff --git a/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx b/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx
index 2f941dbc2d2..eec764c4cd8 100644
--- a/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx
+++ b/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx
@@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button";
const ThemeToggle: React.FC = () => {
const { setTheme, resolvedTheme } = useTheme();
const isDark = resolvedTheme === "dark";
- const label = isDark ? "Switch to light mode" : "Switch to dark mode (beta)";
+ const label = isDark ? "Switch to light mode" : "Switch to dark mode";
return (
, roles: all_admin_roles },
+ {
+ key: "telemetry",
+ page: "telemetry",
+ label: "Telemetry",
+ icon: ,
+ roles: proxyAdminTierRoles,
+ },
],
},
],
diff --git a/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.integration.test.tsx b/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.integration.test.tsx
index c2b8805b892..690721dbbd9 100644
--- a/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.integration.test.tsx
@@ -9,7 +9,7 @@ import userEvent from "@testing-library/user-event";
import { useEffect, type ComponentType } from "react";
import SidebarAccountMenu from "@/components/SidebarAccountMenu/SidebarAccountMenu";
import UserDropdown from "@/components/Navbar/UserDropdown/UserDropdown";
-import LiteAdmin, { LiteAdminFrame } from "./LiteAdmin";
+import LiteAdmin, { LiteAdminFrame, LiteAdminHome } from "./LiteAdmin";
import { MAX_INPUT_LENGTH } from "./agent";
import type { ModelGroup } from "@/components/llm_calls/fetch_models";
@@ -40,7 +40,7 @@ const keyArguments = {
duration: null,
};
-type RecordedRequest = { url: string; headers: Headers; body: Record };
+type RecordedRequest = { url: string; headers: Headers; body: Record; signal: AbortSignal };
type ModelReply = {
role: "assistant";
content: string | null;
@@ -98,9 +98,9 @@ function Page() {
return null;
}
-function renderWidget(Menu?: ComponentType<{ onLogout: () => void }>) {
+function renderWidget(Menu?: ComponentType<{ onLogout: () => void }>, showHome = false) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
- const tree = () => (
+ const tree = (home = showHome) => (
@@ -108,13 +108,14 @@ function renderWidget(Menu?: ComponentType<{ onLogout: () => void }>) {
{Menu && undefined} />}
+ {home && }
);
const view = render(tree());
- return { ...view, refresh: () => view.rerender(tree()), client };
+ return { ...view, refresh: (home = showHome) => view.rerender(tree(home)), client };
}
interface GatewayOptions {
@@ -130,7 +131,8 @@ function gateway(replies: (ModelReply | Promise)[], options: Gateway
transport.mockImplementation(async (input, init) => {
const request = input instanceof Request ? input : new Request(new URL(String(input), "http://localhost"), init);
const body: Record = request.method === "GET" ? {} : await request.clone().json();
- requests.push({ url: request.url, headers: request.headers, body });
+ const recorded = { url: request.url, headers: request.headers, body, signal: request.signal };
+ requests.push(recorded);
const path = new URL(request.url).pathname;
if (path.endsWith("/litellm-ui-config"))
return json({ proxy_base_url: MANAGEMENT, server_root_path: "", admin_ui_disabled: false });
@@ -170,10 +172,10 @@ function gateway(replies: (ModelReply | Promise)[], options: Gateway
return requests;
}
-async function selectModel() {
+async function selectModel(model = "chat-model") {
const user = userEvent.setup();
await user.click(await screen.findByRole("combobox", { name: "LiteAdmin model" }));
- await user.click(await screen.findByRole("option", { name: "chat-model" }));
+ await user.click(await screen.findByRole("option", { name: model }));
}
async function openWidget() {
@@ -182,7 +184,7 @@ async function openWidget() {
}
function send(text: string) {
- fireEvent.change(screen.getByPlaceholderText("Ask LiteAdmin…"), { target: { value: text } });
+ fireEvent.change(screen.getByRole("textbox"), { target: { value: text } });
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
}
@@ -202,13 +204,417 @@ afterEach(() => {
});
describe("LiteAdmin in the gateway", () => {
+ it.each(["keyboard", "click"])("continues one conversation from Home via %s and the dock", async (method) => {
+ const requests = gateway([answer("First reply."), answer("Second reply."), answer("Third reply.")]);
+ renderWidget(undefined, true);
+ const home = await screen.findByRole("region", { name: "Ask LiteAdmin" });
+ await selectModel();
+ const input = within(home).getByPlaceholderText("What would you like to do?");
+ fireEvent.change(input, { target: { value: "First question" } });
+ if (method === "keyboard") fireEvent.keyDown(input, { key: "Enter" });
+ else fireEvent.click(within(home).getByRole("button", { name: "Send message" }));
+ expect(await screen.findByText("First reply.")).toBeInTheDocument();
+ expect(home).not.toBeVisible();
+ expect(screen.getAllByRole("complementary", { name: "LiteAdmin" })).toHaveLength(1);
+ expect(requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(1);
+ send("Second question");
+ expect(await screen.findByText("Second reply.")).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "Close LiteAdmin" }));
+ expect(home).toBeVisible();
+ send("Third question");
+ expect(await screen.findByText("Third reply.")).toBeInTheDocument();
+ const completions = requests.filter((request) => request.url.endsWith("/chat/completions"));
+ expect(completions).toHaveLength(3);
+ expect(completions[2].body.messages).toEqual([
+ { role: "system", content: expect.any(String) },
+ { role: "user", content: "First question" },
+ { role: "assistant", content: "First reply." },
+ { role: "user", content: "Second question" },
+ { role: "assistant", content: "Second reply." },
+ { role: "user", content: "Third question" },
+ ]);
+ });
+
+ it("prefills each Home suggestion without sending until the edited draft is submitted", async () => {
+ const requests = gateway([answer("Budget details received.")]);
+ renderWidget(undefined, true);
+ await selectModel();
+ const input = screen.getByRole("textbox", { name: "Message LiteAdmin" });
+ for (const label of ["Create a key", "Add a model", "Set a budget"]) {
+ fireEvent.click(screen.getByRole("button", { name: label, exact: true }));
+ expect(input).toHaveValue(label);
+ expect(input).toHaveFocus();
+ expect(screen.queryByRole("complementary", { name: "LiteAdmin" })).not.toBeInTheDocument();
+ expect(requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(0);
+ }
+ fireEvent.change(input, { target: { value: "Set a budget of $10 for my team" } });
+ fireEvent.keyDown(input, { key: "Enter", shiftKey: true });
+ fireEvent.keyDown(input, { key: "Enter", isComposing: true });
+ expect(requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(0);
+ fireEvent.keyDown(input, { key: "Enter" });
+ expect(await screen.findByText("Budget details received.")).toBeInTheDocument();
+ const completions = requests.filter((request) => request.url.endsWith("/chat/completions"));
+ expect(completions).toHaveLength(1);
+ expect(completions[0].body.model).toBe("chat-model");
+ expect(completions[0].body.messages).toContainEqual({ role: "user", content: "Set a budget of $10 for my team" });
+ expect(requests.filter((request) => request.url.endsWith("/key/generate"))).toHaveLength(0);
+ });
+
+ it("preserves the draft and chosen model when opening, closing and leaving Home", async () => {
+ const requests = gateway([answer("Draft received.")], {
+ models: [
+ { model_group: "claude-sonnet-5-5", mode: "chat" },
+ { model_group: "chat-model", mode: "chat" },
+ ],
+ });
+ const view = renderWidget(undefined, true);
+ await selectModel();
+ fireEvent.change(screen.getByPlaceholderText("What would you like to do?"), {
+ target: { value: "Keep this draft" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Recent chats" }));
+ expect(screen.getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("Keep this draft");
+ fireEvent.click(screen.getByRole("button", { name: "Close LiteAdmin" }));
+ const home = screen.getByRole("region", { name: "Ask LiteAdmin" });
+ expect(within(home).getByPlaceholderText("What would you like to do?")).toHaveValue("Keep this draft");
+ view.refresh(false);
+ expect(home).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: "LiteAdmin" }));
+ expect(screen.getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("Keep this draft");
+ fireEvent.click(screen.getByRole("button", { name: "Send message" }));
+ expect(await screen.findByText("Draft received.")).toBeInTheDocument();
+ const completion = requests.find((request) => request.url.endsWith("/chat/completions"));
+ expect(completion?.body.model).toBe("chat-model");
+ expect(completion?.body.messages).toContainEqual({ role: "user", content: "Keep this draft" });
+ });
+
+ it("keeps each tab's history, model and draft independent through the Home composer", async () => {
+ const requests = gateway(
+ [answer("First answer."), answer("Second answer."), answer("Second follow-up."), answer("First follow-up.")],
+ {
+ models: [
+ { model_group: "chat-model", mode: "chat" },
+ { model_group: "other-chat-model", mode: "chat" },
+ ],
+ },
+ );
+ renderWidget(undefined, true);
+ await selectModel();
+ send("First question");
+ await screen.findByText("First answer.");
+ expect(within(screen.getByRole("tablist", { name: "LiteAdmin chats" })).getAllByRole("tab")).toHaveLength(1);
+ fireEvent.click(screen.getByRole("button", { name: "New chat" }));
+ expect(screen.getByRole("tab", { name: "Chat 2" })).toHaveAttribute("aria-selected", "true");
+ expect(screen.queryByText("First answer.")).not.toBeInTheDocument();
+ expect(screen.getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("");
+ await selectModel("other-chat-model");
+ send("Second question");
+ await screen.findByText("Second answer.");
+ fireEvent.change(screen.getByPlaceholderText("Ask LiteAdmin…"), { target: { value: "Second draft" } });
+
+ fireEvent.click(screen.getByRole("tab", { name: "Chat 1" }));
+ expect(screen.getByText("First answer.")).toBeInTheDocument();
+ expect(screen.queryByText("Second answer.")).not.toBeInTheDocument();
+ expect(screen.getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("");
+ expect(screen.getByRole("combobox", { name: "LiteAdmin model" })).toHaveValue("chat-model");
+ fireEvent.change(screen.getByPlaceholderText("Ask LiteAdmin…"), { target: { value: "First draft" } });
+ fireEvent.click(screen.getByRole("button", { name: "Close LiteAdmin" }));
+ const home = screen.getByRole("region", { name: "Ask LiteAdmin" });
+ expect(within(home).getByPlaceholderText("What would you like to do?")).toHaveValue("First draft");
+ expect(screen.getAllByPlaceholderText("What would you like to do?")).toHaveLength(1);
+
+ fireEvent.click(screen.getByRole("button", { name: "Recent chats" }));
+ fireEvent.click(screen.getByRole("tab", { name: "Chat 2" }));
+ expect(screen.getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("Second draft");
+ expect(screen.getByRole("combobox", { name: "LiteAdmin model" })).toHaveValue("other-chat-model");
+ expect(screen.getAllByLabelText("LiteAdmin conversation")).toHaveLength(1);
+ fireEvent.click(screen.getByRole("button", { name: "Close LiteAdmin" }));
+ expect(within(home).getByPlaceholderText("What would you like to do?")).toHaveValue("Second draft");
+ fireEvent.click(within(home).getByRole("button", { name: "Send message" }));
+ await screen.findByText("Second follow-up.");
+ expect(screen.getByRole("tab", { name: "Chat 2" })).toHaveAttribute("aria-selected", "true");
+ fireEvent.click(screen.getByRole("tab", { name: "Chat 1" }));
+ expect(screen.getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("First draft");
+ fireEvent.click(screen.getByRole("button", { name: "Send message" }));
+ await screen.findByText("First follow-up.");
+
+ const completions = requests.filter((request) => request.url.endsWith("/chat/completions"));
+ expect(completions.map((request) => request.body.model)).toEqual([
+ "chat-model",
+ "other-chat-model",
+ "other-chat-model",
+ "chat-model",
+ ]);
+ expect(completions[1].body.messages).toEqual([
+ { role: "system", content: expect.any(String) },
+ { role: "user", content: "Second question" },
+ ]);
+ expect(completions[2].body.messages).toEqual([
+ { role: "system", content: expect.any(String) },
+ { role: "user", content: "Second question" },
+ { role: "assistant", content: "Second answer." },
+ { role: "user", content: "Second draft" },
+ ]);
+ expect(completions[3].body.messages).toEqual([
+ { role: "system", content: expect.any(String) },
+ { role: "user", content: "First question" },
+ { role: "assistant", content: "First answer." },
+ { role: "user", content: "First draft" },
+ ]);
+ });
+
+ it("supports keyboard tab switching and keeps focus on a surviving tab after closing", async () => {
+ gateway([]);
+ renderWidget();
+ await openWidget();
+ const user = userEvent.setup();
+ await user.click(screen.getByRole("button", { name: "New chat" }));
+ await user.click(screen.getByRole("tab", { name: "Chat 1" }));
+ await user.keyboard("{ArrowRight}{Enter}");
+ expect(screen.getByRole("tab", { name: "Chat 2" })).toHaveAttribute("aria-selected", "true");
+ expect(screen.getByRole("tab", { name: "Chat 2" })).toHaveFocus();
+ await user.click(screen.getByRole("button", { name: "Close Chat 2" }));
+ expect(screen.getByRole("tab", { name: "Chat 1" })).toHaveFocus();
+ await user.click(screen.getByRole("button", { name: "Close Chat 1" }));
+ expect(screen.getByRole("tab", { name: "Chat 3" })).toHaveFocus();
+ expect(screen.getByRole("tab", { name: "Chat 3" })).toHaveAttribute("aria-selected", "true");
+ });
+
+ it("keeps tab focus when returning to a saved approval and focuses it when reopening the panel", async () => {
+ const requests = gateway([toolReply("key_create", keyArguments)]);
+ renderWidget();
+ await openWidget();
+ send("Prepare a key for approval");
+ const review = await screen.findByRole("region", { name: "Create a virtual key" });
+ expect(review).toHaveFocus();
+ const user = userEvent.setup();
+ await user.click(screen.getByRole("button", { name: "New chat" }));
+ await user.click(screen.getByRole("tab", { name: "Chat 2" }));
+ await user.keyboard("{ArrowLeft}{Enter}");
+ expect(screen.getByRole("tab", { name: "Chat 1" })).toHaveFocus();
+ expect(screen.getByRole("button", { name: "Confirm change" })).toBeInTheDocument();
+ await user.click(screen.getByRole("tab", { name: "Chat 2" }));
+ await user.click(screen.getByRole("button", { name: "Close Chat 2" }));
+ expect(screen.getByRole("tab", { name: "Chat 1" })).toHaveFocus();
+ await user.click(screen.getByRole("button", { name: "Close LiteAdmin" }));
+ await user.click(screen.getByRole("button", { name: "LiteAdmin" }));
+ expect(screen.getByRole("region", { name: "Create a virtual key" })).toHaveFocus();
+ expect(requests.filter((request) => request.url.endsWith("/key/generate"))).toHaveLength(0);
+ });
+
+ it("lets simultaneous turns finish independently without a background review stealing focus", async () => {
+ const first = deferred();
+ const second = deferred();
+ const requests = gateway([first.promise, second.promise]);
+ renderWidget();
+ await openWidget();
+ send("First pending question");
+ await waitFor(() =>
+ expect(requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(1),
+ );
+ fireEvent.click(screen.getByRole("button", { name: "New chat" }));
+ await selectModel();
+ send("Second pending question");
+ await waitFor(() =>
+ expect(requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(2),
+ );
+ const completions = requests.filter((request) => request.url.endsWith("/chat/completions"));
+ expect(completions.every((request) => !request.signal.aborted)).toBe(true);
+ await act(async () => second.resolve(answer("Second completed first.")));
+ await screen.findByText("Second completed first.");
+ const composer = screen.getByPlaceholderText("Ask LiteAdmin…");
+ fireEvent.change(composer, { target: { value: "Keep typing in the second chat" } });
+ act(() => composer.focus());
+
+ await act(async () => first.resolve(toolReply("key_create", keyArguments)));
+ expect(composer).toHaveFocus();
+ expect(composer).toHaveValue("Keep typing in the second chat");
+ expect(composer).toBeEnabled();
+ expect(screen.getByRole("tab", { name: "Chat 2" })).toHaveAttribute("aria-selected", "true");
+ expect(screen.queryByRole("button", { name: "Confirm change" })).not.toBeInTheDocument();
+ expect(screen.queryByText("First pending question")).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole("tab", { name: "Chat 1" }));
+ expect(await screen.findByRole("region", { name: "Create a virtual key" })).toHaveTextContent("Widget key");
+ expect(screen.getByText("First pending question")).toBeInTheDocument();
+ expect(screen.queryByText("Second completed first.")).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole("tab", { name: "Chat 2" }));
+ expect(screen.getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("Keep typing in the second chat");
+ expect(screen.getByText("Second completed first.")).toBeInTheDocument();
+ expect(requests.filter((request) => request.url.endsWith("/key/generate"))).toHaveLength(0);
+ });
+
+ it("cancels only a closed tab's approval and prevents closing an applying write even from another tab", async () => {
+ const response = deferred();
+ const requests = gateway(
+ [
+ toolReply("key_create", keyArguments),
+ answer("Second chat is usable."),
+ toolReply("key_create", { ...keyArguments, key_alias: "Second chat key" }),
+ answer("Third chat is usable."),
+ answer("Second chat write completed."),
+ ],
+ { write: () => response.promise },
+ );
+ renderWidget();
+ await openWidget();
+ send("Propose a first key");
+ await screen.findByRole("button", { name: "Confirm change" });
+ fireEvent.click(screen.getByRole("button", { name: "New chat" }));
+ await selectModel();
+ expect(screen.queryByRole("button", { name: "Confirm change" })).not.toBeInTheDocument();
+ send("Read in the second chat");
+ await screen.findByText("Second chat is usable.");
+ fireEvent.click(screen.getByRole("tab", { name: "Chat 1" }));
+ expect(screen.getByRole("region", { name: "Create a virtual key" })).toHaveTextContent("Widget key");
+ fireEvent.click(screen.getByRole("tab", { name: "Chat 2" }));
+ fireEvent.click(screen.getByRole("button", { name: "Close Chat 1" }));
+ expect(screen.queryByRole("tab", { name: "Chat 1" })).not.toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: "Chat 2" })).toHaveAttribute("aria-selected", "true");
+ expect(requests.filter((request) => request.url.endsWith("/key/generate"))).toHaveLength(0);
+
+ send("Create the second chat key");
+ fireEvent.click(await screen.findByRole("button", { name: "Confirm change" }));
+ await waitFor(() => expect(requests.filter((request) => request.url.endsWith("/key/generate"))).toHaveLength(1));
+ expect(screen.getByRole("button", { name: "Close Chat 2" })).toBeDisabled();
+ fireEvent.click(screen.getByRole("button", { name: "Close Chat 2" }));
+ expect(screen.getByRole("tab", { name: "Chat 2" })).toHaveAttribute("aria-selected", "true");
+ expect(screen.getByRole("button", { name: "New chat" })).toBeEnabled();
+ fireEvent.click(screen.getByRole("button", { name: "New chat" }));
+ expect(screen.getByRole("button", { name: "Close Chat 2" })).toBeDisabled();
+ fireEvent.click(screen.getByRole("button", { name: "Close Chat 2" }));
+ expect(screen.getByRole("tab", { name: "Chat 2" })).toBeInTheDocument();
+ await selectModel();
+ send("Read while the other tab writes");
+ await screen.findByText("Third chat is usable.");
+ await act(async () => response.resolve(json({ key: NEW_KEY })));
+ await waitFor(() => expect(screen.getByRole("button", { name: "Close Chat 2" })).toBeEnabled());
+ expect(screen.getByRole("tab", { name: "Chat 3" })).toHaveAttribute("aria-selected", "true");
+ expect(screen.queryByText(NEW_KEY)).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole("tab", { name: "Chat 2" }));
+ expect(await screen.findByText("Second chat write completed.")).toBeInTheDocument();
+ expect(screen.getByText(NEW_KEY)).toBeInTheDocument();
+ expect(screen.getByRole("region", { name: "Create a virtual key" })).toHaveTextContent("Completed");
+ expect(requests.filter((request) => request.url.endsWith("/key/generate")).map((request) => request.body)).toEqual([
+ { key_alias: "Second chat key", team_id: "team-1", max_budget: 40 },
+ ]);
+ expect(screen.queryByText(/A submitted change may have completed/)).not.toBeInTheDocument();
+ });
+
+ it("closes inactive, active and last tabs without cancelling survivors or reusing closed histories", async () => {
+ const first = deferred();
+ const second = deferred();
+ const requests = gateway([first.promise, second.promise, answer("Third answer."), answer("Fresh answer.")]);
+ renderWidget();
+ await openWidget();
+ send("First pending question");
+ await waitFor(() =>
+ expect(requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(1),
+ );
+ fireEvent.click(screen.getByRole("button", { name: "New chat" }));
+ await selectModel();
+ send("Second pending question");
+ await waitFor(() =>
+ expect(requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(2),
+ );
+ const pending = requests.filter((request) => request.url.endsWith("/chat/completions"));
+ fireEvent.click(screen.getByRole("button", { name: "Close Chat 1" }));
+ await waitFor(() => expect(pending[0].signal.aborted).toBe(true));
+ expect(pending[1].signal.aborted).toBe(false);
+ expect(screen.getByRole("tab", { name: "Chat 2" })).toHaveAttribute("aria-selected", "true");
+ await act(async () => first.resolve(toolReply("key_create", keyArguments)));
+ expect(screen.queryByRole("button", { name: "Confirm change" })).not.toBeInTheDocument();
+ expect(screen.getByRole("status", { name: "LiteAdmin is working" })).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "New chat" }));
+ await selectModel();
+ send("Third question");
+ await screen.findByText("Third answer.");
+ fireEvent.click(screen.getByRole("button", { name: "Close Chat 3" }));
+ expect(screen.getByRole("tab", { name: "Chat 2" })).toHaveAttribute("aria-selected", "true");
+ expect(screen.getByText("Second pending question")).toBeInTheDocument();
+ expect(pending[1].signal.aborted).toBe(false);
+ fireEvent.click(screen.getByRole("button", { name: "Close Chat 2" }));
+ await waitFor(() => expect(pending[1].signal.aborted).toBe(true));
+ expect(within(screen.getByRole("tablist", { name: "LiteAdmin chats" })).getAllByRole("tab")).toHaveLength(1);
+ expect(screen.getByRole("tab", { name: "Chat 4" })).toHaveAttribute("aria-selected", "true");
+ expect(screen.getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("");
+ expect(screen.queryByRole("button", { name: "Stop request" })).not.toBeInTheDocument();
+ await act(async () => second.resolve(answer("Late closed answer.")));
+ expect(screen.queryByText("Late closed answer.")).not.toBeInTheDocument();
+ expect(screen.queryByText("Third answer.")).not.toBeInTheDocument();
+ await selectModel();
+ send("Fresh question");
+ await screen.findByText("Fresh answer.");
+ expect(requests.filter((request) => request.url.endsWith("/chat/completions")).at(-1)?.body.messages).toEqual([
+ { role: "system", content: expect.any(String) },
+ { role: "user", content: "Fresh question" },
+ ]);
+ expect(requests.filter((request) => request.url.endsWith("/key/generate"))).toHaveLength(0);
+ });
+
+ it("keeps Home drafts unsent without a model or with empty or oversized input", async () => {
+ const options: GatewayOptions = { models: [] };
+ const requests = gateway([], options);
+ const { client } = renderWidget(undefined, true);
+ const home = await screen.findByRole("region", { name: "Ask LiteAdmin" });
+ expect(await within(home).findByText("Add a chat model to your gateway to use LiteAdmin.")).toBeInTheDocument();
+ expect(within(home).getByPlaceholderText("What would you like to do?")).toBeDisabled();
+ expect(within(home).getByRole("button", { name: "Send message" })).toBeDisabled();
+ options.models = [{ model_group: "chat-model", mode: "chat" }];
+ await act(async () => client.invalidateQueries({ queryKey: ["liteadmin-models"] }));
+ await selectModel();
+ for (const text of [" ", "x".repeat(MAX_INPUT_LENGTH + 1)]) {
+ const input = within(home).getByPlaceholderText("What would you like to do?");
+ fireEvent.change(input, { target: { value: text } });
+ fireEvent.keyDown(input, { key: "Enter" });
+ const submit = within(home).getByRole("button", { name: "Send message" });
+ expect(submit).toBeDisabled();
+ fireEvent.click(submit);
+ expect(input).toHaveValue(text);
+ }
+ expect(home).toBeVisible();
+ expect(within(home).getByRole("alert")).toHaveTextContent("8,000 characters");
+ expect(screen.queryByRole("complementary", { name: "LiteAdmin" })).not.toBeInTheDocument();
+ expect(requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(0);
+ });
+
+ it("shows destination consent in Home and discards its draft when the destination changes", async () => {
+ const settings = { target: EXTERNAL_INFERENCE, status: 200 };
+ const settingsReady = deferred();
+ const requests = gateway([], {
+ settings: async () => {
+ await settingsReady.promise;
+ return json({ PROXY_BASE_URL: MANAGEMENT, LITELLM_UI_API_DOC_BASE_URL: settings.target });
+ },
+ });
+ const { client } = renderWidget(undefined, true);
+ const home = await screen.findByRole("region", { name: "Ask LiteAdmin" });
+ expect(await within(home).findByLabelText("Loading gateway settings")).toBeInTheDocument();
+ await act(async () => settingsReady.resolve());
+ const consent = await within(home).findByRole("button", { name: "Use configured gateway" });
+ expect(within(home).getByText(EXTERNAL_INFERENCE)).toBeInTheDocument();
+ expect(within(home).queryByPlaceholderText("What would you like to do?")).not.toBeInTheDocument();
+ fireEvent.click(consent);
+ await selectModel();
+ fireEvent.change(within(home).getByPlaceholderText("What would you like to do?"), {
+ target: { value: "Old gateway draft" },
+ });
+ settings.target = `${EXTERNAL_INFERENCE}/changed`;
+ await act(async () => client.invalidateQueries({ queryKey: ["proxySettings"] }));
+ fireEvent.click(await within(home).findByRole("button", { name: "Use configured gateway" }));
+ await selectModel();
+ expect(within(home).getByPlaceholderText("What would you like to do?")).toHaveValue("");
+ expect(home).toBeVisible();
+ expect(requests.some((request) => request.url.endsWith("/chat/completions"))).toBe(false);
+ });
+
it.each([
["sidebar", SidebarAccountMenu],
["navbar", UserDropdown],
] as const)("persists Hide LiteAdmin only for enterprise users in the %s account menu", async (_name, Menu) => {
gateway([]);
const user = userEvent.setup();
- const view = renderWidget(Menu);
+ const view = renderWidget(Menu, true);
await screen.findByRole("button", { name: "LiteAdmin" });
await user.click(screen.getByRole("button", { name: /account menu/i }));
const toggle = await screen.findByRole("switch", { name: "Toggle hide LiteAdmin" });
@@ -216,9 +622,10 @@ describe("LiteAdmin in the gateway", () => {
await user.click(toggle);
expect(toggle).toBeChecked();
expect(screen.queryByRole("button", { name: "LiteAdmin" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("region", { name: "Ask LiteAdmin", hidden: true })).not.toBeInTheDocument();
view.unmount();
- const restored = renderWidget(Menu);
+ const restored = renderWidget(Menu, true);
await screen.findByText("Session ready");
await waitFor(() => expect(restored.client.isFetching()).toBe(0));
expect(screen.queryByRole("button", { name: "LiteAdmin" })).not.toBeInTheDocument();
@@ -227,6 +634,7 @@ describe("LiteAdmin in the gateway", () => {
expect(savedToggle).toBeChecked();
await user.click(savedToggle);
expect(await screen.findByRole("button", { name: "LiteAdmin" })).toBeInTheDocument();
+ expect(await screen.findByRole("region", { name: "Ask LiteAdmin" })).toBeVisible();
for (const premiumUser of [false, null, undefined, "true"]) {
session("proxy_admin", "first-admin", { premium_user: premiumUser });
@@ -241,10 +649,11 @@ describe("LiteAdmin in the gateway", () => {
async (premiumUser) => {
session("proxy_admin", "first-admin", { premium_user: premiumUser });
const requests = gateway([]);
- const { client } = renderWidget();
+ const { client } = renderWidget(undefined, true);
await screen.findByText("Session ready");
await waitFor(() => expect(client.isFetching()).toBe(0));
expect(screen.queryByRole("button", { name: "LiteAdmin" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("region", { name: "Ask LiteAdmin", hidden: true })).not.toBeInTheDocument();
for (const modifiers of [{ metaKey: true }, { ctrlKey: true }]) {
expect(fireEvent.keyDown(document, { key: "j", ...modifiers })).toBe(true);
expect(screen.queryByRole("complementary", { name: "LiteAdmin" })).not.toBeInTheDocument();
@@ -387,10 +796,11 @@ describe("LiteAdmin in the gateway", () => {
async (role) => {
session(role);
const requests = gateway([]);
- const { client } = renderWidget();
+ const { client } = renderWidget(undefined, true);
await screen.findByText("Session ready");
await waitFor(() => expect(client.isFetching()).toBe(0));
expect(screen.queryByRole("button", { name: "LiteAdmin" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("region", { name: "Ask LiteAdmin", hidden: true })).not.toBeInTheDocument();
expect(requests.every((request) => request.url.endsWith("/litellm-ui-config"))).toBe(true);
},
);
@@ -490,7 +900,8 @@ describe("LiteAdmin in the gateway", () => {
const confirm = await screen.findByRole("button", { name: "Confirm change" });
fireEvent.click(confirm);
fireEvent.click(confirm);
- expect(screen.getByRole("button", { name: "New chat" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Close Chat 1" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "New chat" })).toBeEnabled();
expect(screen.queryByRole("button", { name: "Stop request" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Close LiteAdmin" }));
await act(async () => response.resolve(json({ key: NEW_KEY })));
@@ -573,25 +984,73 @@ describe("LiteAdmin in the gateway", () => {
expect(screen.queryByText(/A submitted change may have completed/)).not.toBeInTheDocument();
});
- it.each(["account", "target"])("discards an unconfirmed review when the %s changes", async (transition) => {
- const settings = { target: INFERENCE, status: 200 };
- const requests = gateway([toolReply("key_create", keyArguments)], { settings });
- const view = renderWidget();
- await openWidget();
- send("Old request");
- await screen.findByRole("button", { name: "Confirm change" });
- if (transition === "account") {
- session("proxy_admin", "second-admin");
- view.refresh();
- } else {
- settings.target = `${INFERENCE}/updated`;
- await act(async () => view.client.invalidateQueries({ queryKey: ["proxySettings"] }));
- }
- await waitFor(() => expect(screen.queryByRole("button", { name: "Confirm change" })).not.toBeInTheDocument());
- expect(screen.queryByText("Old request")).not.toBeInTheDocument();
- expect(screen.queryByText(/A submitted change may have completed/)).not.toBeInTheDocument();
- expect(requests.filter((request) => request.url.endsWith("/key/generate"))).toHaveLength(0);
- });
+ it.each(["account", "gateway", "destination", "permission"])(
+ "resets every chat when the %s changes",
+ async (transition) => {
+ const pending = deferred();
+ const settings = { target: INFERENCE, status: 200 };
+ const requests = gateway([pending.promise, toolReply("key_create", keyArguments), answer("Fresh session.")], {
+ settings,
+ });
+ const view = renderWidget(undefined, true);
+ await openWidget();
+ send("Old request");
+ await waitFor(() =>
+ expect(requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(1),
+ );
+ const original = requests.find((request) => request.url.endsWith("/chat/completions"));
+ fireEvent.click(screen.getByRole("button", { name: "New chat" }));
+ await selectModel();
+ send("Old approval");
+ await screen.findByRole("button", { name: "Confirm change" });
+ fireEvent.click(screen.getByRole("button", { name: "New chat" }));
+ await selectModel();
+ fireEvent.change(screen.getByPlaceholderText("Ask LiteAdmin…"), { target: { value: "Old draft" } });
+ if (transition === "account") {
+ session("proxy_admin", "second-admin");
+ view.refresh();
+ } else if (transition === "gateway") {
+ settings.target = "https://worker.test/inference";
+ switchToWorkerUrl("https://worker.test/proxy");
+ view.refresh();
+ } else if (transition === "destination") {
+ settings.target = `${INFERENCE}/updated`;
+ await act(async () => view.client.invalidateQueries({ queryKey: ["proxySettings"] }));
+ } else {
+ session("proxy_admin_viewer");
+ view.refresh();
+ expect(screen.queryByRole("button", { name: "LiteAdmin" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("region", { name: "Ask LiteAdmin", hidden: true })).not.toBeInTheDocument();
+ expect(screen.queryByRole("tablist", { name: "LiteAdmin chats", hidden: true })).not.toBeInTheDocument();
+ fireEvent.keyDown(document, { key: "j", ctrlKey: true });
+ expect(screen.queryByRole("complementary", { name: "LiteAdmin" })).not.toBeInTheDocument();
+ session();
+ view.refresh();
+ }
+ await waitFor(() => expect(original?.signal.aborted).toBe(true));
+ const trigger = await screen.findByRole("button", { name: "LiteAdmin" });
+ if (trigger.getAttribute("aria-expanded") === "false") fireEvent.click(trigger);
+ const tabs = await screen.findByRole("tablist", { name: "LiteAdmin chats" });
+ expect(within(tabs).getAllByRole("tab")).toHaveLength(1);
+ expect(screen.getByRole("tab", { name: "Chat 1" })).toHaveAttribute("aria-selected", "true");
+ await waitFor(() => expect(screen.queryByRole("button", { name: "Confirm change" })).not.toBeInTheDocument());
+ expect(screen.queryByText("Old request")).not.toBeInTheDocument();
+ expect(screen.queryByText("Old approval")).not.toBeInTheDocument();
+ expect(screen.getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("");
+ expect(screen.getByRole("combobox", { name: "LiteAdmin model" })).toHaveValue("");
+ await act(async () => pending.resolve(toolReply("key_create", keyArguments)));
+ expect(screen.queryByRole("button", { name: "Confirm change" })).not.toBeInTheDocument();
+ expect(screen.queryByText(/A submitted change may have completed/)).not.toBeInTheDocument();
+ expect(requests.filter((request) => request.url.endsWith("/key/generate"))).toHaveLength(0);
+ await selectModel();
+ send("Fresh question");
+ await screen.findByText("Fresh session.");
+ expect(requests.filter((request) => request.url.endsWith("/chat/completions")).at(-1)?.body.messages).toEqual([
+ { role: "system", content: expect.any(String) },
+ { role: "user", content: "Fresh question" },
+ ]);
+ },
+ );
it.each(["account", "target"])(
"warns about an interrupted write when the %s changes and rejects its late key",
@@ -641,9 +1100,9 @@ describe("LiteAdmin in the gateway", () => {
it("blocks inference when settings cannot be loaded", async () => {
const requests = gateway([], { settings: { target: INFERENCE, status: 503 } });
- renderWidget();
- fireEvent.click(await screen.findByRole("button", { name: "LiteAdmin" }));
- expect(await screen.findByText("Could not load gateway settings.")).toBeInTheDocument();
+ renderWidget(undefined, true);
+ const home = await screen.findByRole("region", { name: "Ask LiteAdmin" });
+ expect(await within(home).findByText("Could not load gateway settings.")).toBeInTheDocument();
expect(requests.some((request) => request.url.endsWith("/chat/completions"))).toBe(false);
});
diff --git a/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.tsx b/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.tsx
index 38e4334763f..350379588c1 100644
--- a/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.tsx
+++ b/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.tsx
@@ -1,8 +1,20 @@
"use client";
-import { createContext, useContext, useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
-import { useQuery } from "@tanstack/react-query";
-import { RotateCcw, Sparkles, X } from "lucide-react";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+ type ComponentProps,
+ type ReactNode,
+ type RefObject,
+} from "react";
+import { createPortal } from "react-dom";
+import { useQuery, type UseQueryResult } from "@tanstack/react-query";
+import { History, Plus, Sparkles, X } from "lucide-react";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useDisableLiteAdmin } from "@/app/(dashboard)/hooks/useDisableLiteAdmin";
import { useProxySettingsQuery } from "@/app/(dashboard)/hooks/proxySettings/useProxySettings";
@@ -16,14 +28,24 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { FieldError } from "@/components/ui/field";
import { Skeleton } from "@/components/ui/skeleton";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/cva.config";
import { isProxyAdminRole } from "@/utils/roles";
import { getPreferredLiteAdminModel, MAX_INPUT_LENGTH, resolveInferenceTarget } from "./agent";
import { LiteAdminConversation } from "./LiteAdminConversation";
-import { useLiteAdmin, type LiteAdminSession } from "./useLiteAdmin";
+import { LiteAdminHomeComposer } from "./LiteAdminHomeComposer";
+import { useLiteAdmin, type ConversationEntry, type LiteAdminSession } from "./useLiteAdmin";
type ManagementSession = Omit;
-type LiteAdminState = { open: boolean; toggle: () => void };
+type ChatPhase = ReturnType["phase"];
+type ChatTab = { id: number; phase: ChatPhase };
+type LiteAdminState = {
+ open: boolean;
+ toggle: () => void;
+ show: () => void;
+ homeContainer: HTMLDivElement | null;
+ setHomeContainer: (container: HTMLDivElement | null) => void;
+};
const LiteAdminContext = createContext(null);
@@ -43,12 +65,16 @@ export function LiteAdminFrame({ children }: { children: ReactNode }) {
const configured = useLiteAdminSession();
const sessionKey = configured?.key ?? null;
const [open, setOpen] = useState(false);
+ const [homeContainer, setHomeContainer] = useState(null);
const [openedSession, setOpenedSession] = useState(sessionKey);
if (openedSession !== sessionKey) {
setOpenedSession(sessionKey);
setOpen(false);
}
const toggle = () => setOpen((current) => !current);
+ const show = () => {
+ if (!open) setOpen(true);
+ };
useEffect(() => {
if (sessionKey === null) return;
const onKeyDown = (event: KeyboardEvent) => {
@@ -62,7 +88,7 @@ export function LiteAdminFrame({ children }: { children: ReactNode }) {
return () => document.removeEventListener("keydown", onKeyDown);
}, [sessionKey]);
return (
-
+
{children}
{configured && (
setOpen(false)} />
@@ -71,6 +97,30 @@ export function LiteAdminFrame({ children }: { children: ReactNode }) {
);
}
+export function LiteAdminHome() {
+ const state = useContext(LiteAdminContext);
+ if (!state) return null;
+ const { open, show, setHomeContainer } = state;
+ return (
+
+
+
+
+
+ LiteAdmin
+
+
Control your entire gateway.
+
+
+
+ Recent chats
+
+
+
+
+ );
+}
+
export default function LiteAdminTrigger() {
const state = useContext(LiteAdminContext);
if (!state) return null;
@@ -129,39 +179,43 @@ function Destination({
}) {
const [approved, setApproved] = useState(false);
if (target?.baseUrl && (!target.requiresConsent || approved)) {
- return ;
+ return ;
}
return (
-
+
+ {loading && }
+ {!loading && !target?.baseUrl && (
+
+ {target?.error || "Could not load gateway settings."}
+
+ Retry
+
+
+ )}
+ {!loading && target?.baseUrl && (
+
+
+ Connect to the configured gateway
+
+
+ {target.baseUrl}
+
+ This gateway uses a different address. LiteAdmin will send your existing session credential to the
+ address shown above.
+
+
+
+ setApproved(true)}>Use configured gateway
+
+
+ )}
+
+ }
+ >
-
- {loading &&
}
- {!loading && !target?.baseUrl && (
-
- {target?.error || "Could not load gateway settings."}
-
- Retry
-
-
- )}
- {!loading && target?.baseUrl && (
-
-
- Connect to the configured gateway
-
-
- {target.baseUrl}
-
- This gateway uses a different address. LiteAdmin will send your existing session credential to the
- address shown above.
-
-
-
- setApproved(true)}>Use configured gateway
-
-
- )}
-
);
}
@@ -170,12 +224,16 @@ function Destination({
function Panel({
open,
initialFocus,
+ composer,
children,
}: {
open: boolean;
initialFocus?: RefObject;
+ composer: ReactNode;
children: ReactNode;
}) {
+ const state = useContext(LiteAdminContext);
+ const homeContainer = !open ? state?.homeContainer : null;
const ref = useRef(null);
useEffect(() => {
const panel = ref.current;
@@ -196,19 +254,154 @@ function Panel({
)}
>
{children}
+ {homeContainer ? createPortal(composer, homeContainer) : composer}
);
}
-function LiteAdminChat({ session, open, close }: { session: LiteAdminSession; open: boolean; close: () => void }) {
+function LiteAdminChats({ session, open, close }: { session: LiteAdminSession; open: boolean; close: () => void }) {
+ const [tabs, setTabs] = useState<{ chats: ChatTab[]; activeId: number; nextId: number }>({
+ chats: [{ id: 1, phase: "idle" }],
+ activeId: 1,
+ nextId: 2,
+ });
+ const reviewRef = useRef(null);
+ const activeTabRef = useRef(null);
+ const focusAfterClose = useRef(false);
+ useLayoutEffect(() => {
+ if (!focusAfterClose.current) return;
+ focusAfterClose.current = false;
+ activeTabRef.current?.focus();
+ });
+ const addChat = () => {
+ setTabs((current) => ({
+ chats: [...current.chats, { id: current.nextId, phase: "idle" }],
+ activeId: current.nextId,
+ nextId: current.nextId + 1,
+ }));
+ };
+ const closeChat = (id: number) => {
+ if (tabs.chats.some((chat) => chat.id === id && chat.phase === "applying")) return;
+ focusAfterClose.current = true;
+ setTabs((current) => {
+ const closing = current.chats.find((chat) => chat.id === id);
+ if (!closing || closing.phase === "applying") return current;
+ const remaining = current.chats.filter((chat) => chat.id !== id);
+ const next = remaining.find((chat) => chat.id > id) ?? remaining.at(-1);
+ if (!next) {
+ return {
+ chats: [{ id: current.nextId, phase: "idle" }],
+ activeId: current.nextId,
+ nextId: current.nextId + 1,
+ };
+ }
+ return { ...current, chats: remaining, activeId: current.activeId === id ? next.id : current.activeId };
+ });
+ };
+ const updatePhase = useCallback((id: number, phase: ChatPhase) => {
+ setTabs((current) => {
+ if (!current.chats.some((chat) => chat.id === id && chat.phase !== phase)) return current;
+ return { ...current, chats: current.chats.map((chat) => (chat.id === id ? { ...chat, phase } : chat)) };
+ });
+ }, []);
+ return (
+ {
+ if (typeof id === "number") setTabs((current) => ({ ...current, activeId: id }));
+ }}
+ className="contents"
+ >
+
+
+
+
+
+
+
+ {tabs.chats.map((tab) => (
+
+
+ Chat {tab.id}
+ {tab.phase !== "idle" && }
+
+ closeChat(tab.id)}
+ >
+
+
+
+ ))}
+
+ {tabs.chats.map((tab) => (
+
+ ))}
+
+
+ );
+}
+
+function useForegroundReviewFocus(
+ entries: ConversationEntry[],
+ foreground: boolean,
+ reviewRef: RefObject,
+) {
+ const lastReviewId = useRef(null);
+ const pendingReviewId = entries.find((entry) => entry.kind === "action" && entry.status === "review")?.id ?? null;
+ useEffect(() => {
+ if (lastReviewId.current === pendingReviewId) return;
+ lastReviewId.current = pendingReviewId;
+ if (foreground && pendingReviewId) reviewRef.current?.focus({ preventScroll: true });
+ }, [foreground, pendingReviewId, reviewRef]);
+}
+
+function LiteAdminChat({
+ id,
+ session,
+ active,
+ open,
+ reviewRef,
+ onPhaseChange,
+}: {
+ id: number;
+ session: LiteAdminSession;
+ active: boolean;
+ open: boolean;
+ reviewRef: RefObject;
+ onPhaseChange: (id: number, phase: ChatPhase) => void;
+}) {
+ const state = useContext(LiteAdminContext);
const chat = useLiteAdmin(session);
const [input, setInput] = useState("");
const [model, setModel] = useState(null);
- const reviewRef = useRef(null);
+ useForegroundReviewFocus(chat.entries, active && open, reviewRef);
+ useEffect(() => onPhaseChange(id, chat.phase), [id, chat.phase, onPhaseChange]);
const modelQuery = {
queryKey: ["liteadmin-models", session.accessToken, session.managementBaseUrl],
queryFn: () => fetchAvailableModels(session.accessToken),
- enabled: open,
+ enabled: active && (open || Boolean(state?.homeContainer)),
select: (available: ModelGroup[]) =>
available.filter((item) => isModeCompatibleWithEndpoint(item.mode, EndpointType.CHAT)),
};
@@ -221,26 +414,33 @@ function LiteAdminChat({ session, open, close }: { session: LiteAdminSession; op
const submitDisabled = busy || models.isError || !hasValidInput;
const send = () => {
if (submitDisabled || !selectedModel) return;
+ state?.show();
void chat.send(input, selectedModel);
setInput("");
};
+ if (!active) return null;
+ const homeContainer = !open ? state?.homeContainer : null;
+ const composerProps = {
+ value: input,
+ onChange: setInput,
+ onSubmit: send,
+ onCancel: chat.phase === "applying" ? undefined : chat.stop,
+ disabled: busy || !selectedModel,
+ isLoading: busy,
+ submitDisabled,
+ };
+ const composer = (
+
+ );
return (
-
-
- {
- chat.reset();
- setInput("");
- }}
- >
-
-
-
+
-
- {models.isPending ? (
-
- ) : (
-
({ value: item.model_group, label: item.model_group }))}
- value={selectedModel}
- onValueChange={setModel}
- placeholder="Choose a chat model"
- disabled={busy}
- allowClear={false}
- />
- )}
- {models.isError && (
-
-
- Could not load models.{" "}
- void models.refetch()}>
- Retry
-
-
-
- )}
- {models.isSuccess && models.data.length === 0 && (
-
- Add a chat model to your gateway to use LiteAdmin.
-
- )}
- {tooLong && Keep your message within {MAX_INPUT_LENGTH.toLocaleString()} characters. }
-
+ );
+}
+
+function LiteAdminComposer({
+ models,
+ model,
+ onModelChange,
+ home,
+ tooLong,
+ ...props
+}: Omit, "modelPicker"> & {
+ models: UseQueryResult;
+ model: string | null;
+ onModelChange: (value: string | null) => void;
+ home: boolean;
+ tooLong: boolean;
+}) {
+ return (
+
+ {!home &&
}
+
+ {tooLong &&
Keep your message within {MAX_INPUT_LENGTH.toLocaleString()} characters. }
+ {home ? (
+
+ }
/>
-
Use a model you trust with your gateway data.
-
-
+ ) : (
+ <>
+
+ Use a model you trust with your gateway data.
+ >
+ )}
+
+ );
+}
+
+function ModelPicker({
+ models,
+ value,
+ onChange,
+ disabled,
+ compact = false,
+}: {
+ models: UseQueryResult;
+ value: string | null;
+ onChange: (value: string | null) => void;
+ disabled: boolean;
+ compact?: boolean;
+}) {
+ if (models.isPending) return ;
+ return (
+ ({ value: item.model_group, label: item.model_group }))}
+ value={value}
+ onValueChange={onChange}
+ placeholder="Choose a chat model"
+ disabled={disabled}
+ allowClear={false}
+ className={compact ? "border-transparent shadow-none dark:bg-transparent" : undefined}
+ />
+ );
+}
+
+function ModelStatus({ models }: { models: UseQueryResult }) {
+ return (
+ <>
+ {models.isError && (
+
+
+ Could not load models.{" "}
+ void models.refetch()}>
+ Retry
+
+
+
+ )}
+ {models.isSuccess && models.data.length === 0 && (
+
+ Add a chat model to your gateway to use LiteAdmin.
+
+ )}
+ >
);
}
diff --git a/ui/litellm-dashboard/src/components/liteadmin/LiteAdminConversation.tsx b/ui/litellm-dashboard/src/components/liteadmin/LiteAdminConversation.tsx
index 826db57a736..014597dba39 100644
--- a/ui/litellm-dashboard/src/components/liteadmin/LiteAdminConversation.tsx
+++ b/ui/litellm-dashboard/src/components/liteadmin/LiteAdminConversation.tsx
@@ -99,8 +99,7 @@ function ActionCard({
useEffect(() => {
if (!open || !review) return;
scrollToEnd();
- reviewRef.current?.focus({ preventScroll: true });
- }, [entry.id, open, review, reviewRef, scrollToEnd]);
+ }, [entry.id, open, review, scrollToEnd]);
const answer = (approved: boolean) => {
scrollToEnd();
onAnswer(entry.id, approved);
diff --git a/ui/litellm-dashboard/src/components/liteadmin/LiteAdminHomeComposer.tsx b/ui/litellm-dashboard/src/components/liteadmin/LiteAdminHomeComposer.tsx
new file mode 100644
index 00000000000..6b666aa238f
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/liteadmin/LiteAdminHomeComposer.tsx
@@ -0,0 +1,92 @@
+import { useRef, type ReactNode } from "react";
+import { ArrowUp, Box, CreditCard, KeyRound, Square } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { InputGroup, InputGroupTextarea } from "@/components/ui/input-group";
+
+const suggestions = [
+ { label: "Create a key", icon: KeyRound },
+ { label: "Add a model", icon: Box },
+ { label: "Set a budget", icon: CreditCard },
+];
+
+export function LiteAdminHomeComposer({
+ value,
+ onChange,
+ onSubmit,
+ onCancel,
+ disabled,
+ isLoading,
+ submitDisabled,
+ modelPicker,
+}: {
+ value: string;
+ onChange: (value: string) => void;
+ onSubmit: () => void;
+ onCancel?: () => void;
+ disabled: boolean;
+ isLoading: boolean;
+ submitDisabled: boolean;
+ modelPicker: ReactNode;
+}) {
+ const inputRef = useRef(null);
+ return (
+
+ onChange(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
+ event.preventDefault();
+ if (!submitDisabled && !isLoading) onSubmit();
+ }
+ }}
+ placeholder="What would you like to do?"
+ aria-label="Message LiteAdmin"
+ disabled={disabled}
+ rows={2}
+ className="min-h-24 max-h-48 w-full rounded-t-xl px-5 pt-5 pb-2 text-sm placeholder:text-muted-foreground [field-sizing:content]"
+ />
+
+
+ {suggestions.map(({ label, icon: Icon }) => (
+ {
+ onChange(label);
+ inputRef.current?.focus();
+ }}
+ >
+
+ {label}
+
+ ))}
+
+
+
{modelPicker}
+ {isLoading && onCancel ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/components/page_metadata.ts b/ui/litellm-dashboard/src/components/page_metadata.ts
index 72bed431421..18f136c3b28 100644
--- a/ui/litellm-dashboard/src/components/page_metadata.ts
+++ b/ui/litellm-dashboard/src/components/page_metadata.ts
@@ -43,6 +43,7 @@ export const pageDescriptions: Record = {
skills: "Browse and manage Claude Code skills",
usage: "View legacy usage dashboard",
"router-settings": "Configure routing and load balancing settings",
+ telemetry: "Choose which anonymous usage telemetry this proxy collects and see the last report",
"logging-and-alerts": "Set up logging and alert configurations",
"admin-panel": "Access admin panel and settings",
};
diff --git a/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx b/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx
index 21d38e9458c..3649114826d 100644
--- a/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx
+++ b/ui/litellm-dashboard/src/components/shared/SearchSelect.tsx
@@ -30,6 +30,7 @@ interface SearchSelectProps {
inputId?: string;
allowClear?: boolean;
"aria-label"?: string;
+ id?: string;
}
const matchesQuery = (option: SearchSelectOption, query: string): boolean => {
@@ -49,6 +50,7 @@ export function SearchSelect({
inputId,
allowClear = true,
"aria-label": ariaLabel,
+ id,
}: SearchSelectProps) {
const selected =
value == null || value === "" ? null : options.find((option) => option.value === value) ?? { label: value, value };
@@ -66,7 +68,7 @@ export function SearchSelect({
disabled={disabled}
>
!(error instanceof ApiError) ||
export const shouldRetry = (failureCount: number, error: unknown): boolean =>
isRetryable(error) && failureCount < MAX_RETRIES;
-const queryClient = new QueryClient({ defaultOptions: { queries: { retry: shouldRetry } } });
+export const queryClient = new QueryClient({ defaultOptions: { queries: { retry: shouldRetry } } });
export default function ReactQueryProvider({ children }: { children: React.ReactNode }) {
return {children} ;
diff --git a/ui/litellm-dashboard/src/hooks/useUrlTab.test.tsx b/ui/litellm-dashboard/src/hooks/useUrlTab.test.tsx
index 427d454ee2d..cd4951d76dd 100644
--- a/ui/litellm-dashboard/src/hooks/useUrlTab.test.tsx
+++ b/ui/litellm-dashboard/src/hooks/useUrlTab.test.tsx
@@ -2,8 +2,12 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import { NuqsTestingAdapter, type OnUrlUpdateFunction } from "nuqs/adapters/testing";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
+import { recordUiEvent } from "@/lib/telemetry/uiEvents";
import { useUrlTab } from "./useUrlTab";
+vi.mock("@/lib/telemetry/uiEvents", () => ({ recordUiEvent: vi.fn(() => Promise.resolve()) }));
+vi.mock("next/navigation", () => ({ usePathname: () => "/ui/playground" }));
+
const TABS = ["chat", "compare", "compliance"] as const;
type Tab = (typeof TABS)[number];
@@ -97,4 +101,18 @@ describe("useUrlTab", () => {
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
expect(lastUrlUpdate(onUrlUpdate)?.searchParams.has("tab")).toBe(false);
});
+
+ it("records a tab click with the caller's key and the selected tab, and nothing for a tab read from the URL", () => {
+ vi.mocked(recordUiEvent).mockClear();
+ const { result } = renderUrlTab({ searchParams: "?view=compare", key: "view" });
+ expect(recordUiEvent).not.toHaveBeenCalled();
+
+ act(() => result.current[1]("compliance"));
+
+ expect(recordUiEvent).toHaveBeenCalledExactlyOnceWith({
+ page: "playground",
+ action: "click",
+ target: "view=compliance",
+ });
+ });
});
diff --git a/ui/litellm-dashboard/src/hooks/useUrlTab.ts b/ui/litellm-dashboard/src/hooks/useUrlTab.ts
index 2f3d705c610..e5e27974cd7 100644
--- a/ui/litellm-dashboard/src/hooks/useUrlTab.ts
+++ b/ui/litellm-dashboard/src/hooks/useUrlTab.ts
@@ -1,12 +1,22 @@
+import { usePathname } from "next/navigation";
import { parseAsString, useQueryState } from "nuqs";
import { useCallback, useEffect } from "react";
+import { recordUiEvent } from "@/lib/telemetry/uiEvents";
+import { routeSegmentForPathname } from "@/utils/uiHref";
export function useUrlTab(values: readonly T[], fallback: T, key = "tab"): [T, (tab: T) => void] {
const [urlTab, setUrlTab] = useQueryState(key, parseAsString.withDefault(fallback));
+ const page = routeSegmentForPathname(usePathname() ?? "") || "home";
const tab = values.find((value) => value === urlTab) ?? fallback;
useEffect(() => {
if (urlTab !== tab) void setUrlTab(null);
}, [urlTab, tab, setUrlTab]);
- const setTab = useCallback((next: T) => void setUrlTab(next), [setUrlTab]);
+ const setTab = useCallback(
+ (next: T) => {
+ void recordUiEvent({ page, action: "click", target: `${key}=${next}` });
+ void setUrlTab(next);
+ },
+ [page, key, setUrlTab],
+ );
return [tab, setTab];
}
diff --git a/ui/litellm-dashboard/src/lib/http/api.ts b/ui/litellm-dashboard/src/lib/http/api.ts
index 560682ec312..968624ae75c 100644
--- a/ui/litellm-dashboard/src/lib/http/api.ts
+++ b/ui/litellm-dashboard/src/lib/http/api.ts
@@ -28,7 +28,7 @@ const carriesJson = async (response: Response): Promise => {
}
};
-const middleware: Middleware = {
+const jsonMiddleware = (report: (message: string) => void): Middleware => ({
onRequest({ request }) {
if (!request.headers.has("Accept")) {
request.headers.set("Accept", "application/json");
@@ -43,7 +43,7 @@ const middleware: Middleware = {
if (await carriesJson(response)) return response;
const contentType = response.headers.get("content-type") ?? "an unknown content type";
const message = `Expected JSON from ${new URL(request.url).pathname} but the server returned ${contentType}`;
- reportError(message);
+ report(message);
throw new ApiError(message, response.status, await response.clone().text());
}
const raw = await response.clone().text();
@@ -55,10 +55,10 @@ const middleware: Middleware = {
} catch {
message = raw || `HTTP ${response.status}`;
}
- reportError(message);
+ report(message);
throw new ApiError(message, response.status, body, retryAfterMs(response.headers));
},
-};
+});
/**
* The typed, schema-bound HTTP client. Use it inside TanStack Query hooks
@@ -76,7 +76,13 @@ export const fetchClient = createFetchClient({
Request: BaseAwareRequest,
fetch: (request) => globalThis.fetch(request),
});
-fetchClient.use(middleware);
+fetchClient.use(jsonMiddleware(reportError));
+
+export const backgroundFetchClient = createFetchClient({
+ Request: BaseAwareRequest,
+ fetch: (request) => globalThis.fetch(request),
+});
+backgroundFetchClient.use(jsonMiddleware(() => {}));
/**
* TanStack Query bound to the typed client. Callers write
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 62a9d3943cf..bcae95c6789 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -9142,6 +9142,26 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/live/sessions": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * WebSocket: live_sessions_websocket_endpoint
+ * @description WebSocket connection endpoint
+ */
+ get: operations["websocket_live_sessions_websocket_endpoint_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/login": {
parameters: {
query?: never;
@@ -10822,6 +10842,26 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/openai/v1/live/sessions": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * WebSocket: live_sessions_websocket_endpoint
+ * @description WebSocket connection endpoint
+ */
+ get: operations["websocket_live_sessions_websocket_endpoint_get_3"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/openai/v1/realtime": {
parameters: {
query?: never;
@@ -13454,9 +13494,9 @@ export interface paths {
};
/**
* Get Litellm Blog Posts
- * @description Public endpoint to get the latest LiteLLM blog posts.
+ * @description Public endpoint to get the latest LiteLLM blog posts, each with its RSS categories as ``tags``.
*
- * Fetches from GitHub with a 1-hour in-process cache.
+ * Fetches the docs RSS feed with a 1-hour in-process cache.
* Falls back to the bundled local backup on any failure.
*/
get: operations["get_litellm_blog_posts_public_litellm_blog_posts_get"];
@@ -13661,31 +13701,6 @@ export interface paths {
patch?: never;
trace?: never;
};
- "/public/whats_new": {
- parameters: {
- query?: never;
- header?: never;
- path?: never;
- cookie?: never;
- };
- /**
- * Get Public Whats New
- * @description Return the launches the dashboard Home page shows under What's new.
- *
- * Serves the list bundled with this release. Set ``LITELLM_WHATS_NEW_URL`` to fetch a remote list instead,
- * resolved once per process on the first request and falling back to the bundled list on any failure, so an
- * airgapped proxy serves the bundled list without retrying. ``LITELLM_LOCAL_WHATS_NEW=True`` forces the
- * bundled list even when a URL is set. A restart picks up a newly published list.
- */
- get: operations["get_public_whats_new_public_whats_new_get"];
- put?: never;
- post?: never;
- delete?: never;
- options?: never;
- head?: never;
- patch?: never;
- trace?: never;
- };
"/queue/chat/completions": {
parameters: {
query?: never;
@@ -17481,6 +17496,91 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/telemetry/reports": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Export Telemetry Reports
+ * @description Stored telemetry reports, oldest first, for installs that keep telemetry local instead of sending it.
+ * Page with ``after=next_after&after_id=next_after_id`` until ``next_after`` is null. Another worker can commit a
+ * report just after a newer one is exported, so resume a later export a few windows before the last cursor and skip
+ * ids already seen
+ */
+ get: operations["export_telemetry_reports_telemetry_reports_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/telemetry/settings": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Telemetry Settings
+ * @description Which telemetry groups are on, whether env vars control them, where reports go and a last or sample report.
+ * Nothing is recorded until a destination is chosen by ``LITELLM_TELEMETRY_DESTINATION`` or a saved setting
+ */
+ get: operations["get_telemetry_settings_telemetry_settings_get"];
+ /**
+ * Update Telemetry Settings
+ * @description Store the telemetry groups and destination for every worker, applied from the next report window
+ */
+ put: operations["update_telemetry_settings_telemetry_settings_put"];
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/telemetry/ui_events": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Record Ui Event
+ * @description Count one Admin UI page view or tab click. Events are dropped unless Page navigation is on
+ */
+ post: operations["record_ui_event_telemetry_ui_events_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/telemetry/ui_events/enabled": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Ui Events Enabled */
+ get: operations["ui_events_enabled_telemetry_ui_events_enabled_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/test": {
parameters: {
query?: never;
@@ -20293,6 +20393,26 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/v1/live/sessions": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * WebSocket: live_sessions_websocket_endpoint
+ * @description WebSocket connection endpoint
+ */
+ get: operations["websocket_live_sessions_websocket_endpoint_get_2"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/v1/logs": {
parameters: {
query?: never;
@@ -27056,6 +27176,11 @@ export interface components {
date: string;
/** Description */
description: string;
+ /**
+ * Tags
+ * @default []
+ */
+ tags: string[];
/** Title */
title: string;
/** Url */
@@ -29603,7 +29728,7 @@ export interface components {
enable_public_model_hub: boolean;
/**
* Enforce Fallback Model Access
- * @description If True, router fallbacks configured in router_settings are only attempted when the calling key (and its team and project) is allowed to call the fallback model; unauthorized fallback targets are skipped and the primary model's error is returned. Default is False.
+ * @description If True, router fallbacks configured in router_settings are only attempted when the calling key (and its team and project) is allowed to call the fallback model; unauthorized fallback targets are skipped and the primary model's error is returned. Default is True. Set False to disable fallback model access enforcement.
*/
enforce_fallback_model_access?: boolean | null;
/**
@@ -36626,6 +36751,11 @@ export interface components {
* @description Custom message when a guardrail blocks an action. Supports placeholders like {tool_name}, {rule_id}, and {default_message}.
*/
violation_message_template?: string | null;
+ /**
+ * Wingback App Id
+ * @description Wingback external gateway integration name for request attribution (sent as additional_provider_specific_params.wingback_app_id).
+ */
+ wingback_app_id?: string | null;
/**
* Xecguard Model
* @description XecGuard scanning model identifier. Defaults to 'xecguard_v2'.
@@ -37633,6 +37763,8 @@ export interface components {
reasoning_content?: string | null;
/** Reasoning Items */
reasoning_items?: components["schemas"]["ChatCompletionReasoningItem"][] | null;
+ /** Refusal */
+ refusal?: string | null;
/**
* Role
* @enum {string}
@@ -45295,6 +45427,16 @@ export interface components {
*/
user_ids: string[];
};
+ /** StoredReport */
+ StoredReport: {
+ /** Id */
+ id: string;
+ report: components["schemas"]["JsonValue"];
+ /** Window End */
+ window_end: number;
+ /** Window Start */
+ window_start: number;
+ };
/**
* SuccessfulKeyUpdate
* @description Successfully updated key with its updated information
@@ -46114,6 +46256,71 @@ export interface components {
/** User Id */
user_id: string;
};
+ /**
+ * TelemetryDestination
+ * @enum {string}
+ */
+ TelemetryDestination: "local_table" | "http";
+ /**
+ * TelemetryGroup
+ * @description A telemetry group describes a group of metrics that can be toggled on/off separately. One
+ * group may have dependencies on another.
+ * @enum {string}
+ */
+ TelemetryGroup: "heartbeat" | "request_success" | "token_info" | "request_taxonomy" | "event_details" | "instance_configuration" | "page_navigation";
+ /** TelemetryGroupInfo */
+ TelemetryGroupInfo: {
+ /** Enabled */
+ enabled: boolean;
+ group: components["schemas"]["TelemetryGroup"];
+ requires: components["schemas"]["TelemetryGroup"] | null;
+ };
+ /** TelemetryReportsResponse */
+ TelemetryReportsResponse: {
+ /** Next After */
+ next_after: number | null;
+ /** Next After Id */
+ next_after_id: string | null;
+ /** Reports */
+ reports: components["schemas"]["StoredReport"][];
+ };
+ /** TelemetrySettingsResponse */
+ TelemetrySettingsResponse: {
+ /** Available Destinations */
+ available_destinations: components["schemas"]["TelemetryDestination"][];
+ destination: components["schemas"]["TelemetryDestination"] | null;
+ /** Destination Set By Environment */
+ destination_set_by_environment: boolean;
+ /** Editable */
+ editable: boolean;
+ /** Environment Variables */
+ environment_variables: string[];
+ /** Flush Interval Seconds */
+ flush_interval_seconds: number;
+ /** Groups */
+ groups: components["schemas"]["TelemetryGroupInfo"][];
+ /** Report */
+ report: {
+ [key: string]: components["schemas"]["JsonValue"];
+ } | null;
+ /** Report Is Sample */
+ report_is_sample: boolean;
+ /** Retention Days */
+ retention_days: number;
+ /** Set By Environment */
+ set_by_environment: boolean;
+ stored_destination: components["schemas"]["TelemetryDestination"] | null;
+ /** Stored Groups */
+ stored_groups: components["schemas"]["TelemetryGroup"][] | null;
+ /** Vetoed */
+ vetoed: boolean;
+ };
+ /** TelemetrySettingsUpdate */
+ TelemetrySettingsUpdate: {
+ destination?: components["schemas"]["TelemetryDestination"] | null;
+ /** Groups */
+ groups: string[];
+ };
/**
* TestCustomCodeGuardrailRequest
* @description Request model for testing custom code guardrails.
@@ -47099,6 +47306,33 @@ export interface components {
} & {
[key: string]: unknown;
};
+ /**
+ * UIAction
+ * @enum {string}
+ */
+ UIAction: "view" | "click" | "submit";
+ /** UIEventAccepted */
+ UIEventAccepted: {
+ /**
+ * Accepted
+ * @default true
+ * @constant
+ */
+ accepted: true;
+ };
+ /** UIEventBody */
+ UIEventBody: {
+ action: components["schemas"]["UIAction"];
+ /** Page */
+ page: string;
+ /** Target */
+ target?: string | null;
+ };
+ /** UIEventsEnabledResponse */
+ UIEventsEnabledResponse: {
+ /** Enabled */
+ enabled: boolean;
+ };
/** UIField */
UIField: {
/** Key */
@@ -49304,30 +49538,6 @@ export interface components {
type: "web_search" | "web_search_2025_08_26";
user_location?: components["schemas"]["openai__types__responses__web_search_tool_param__UserLocation"] | null;
};
- /**
- * WhatsNewLaunch
- * @description One launch card in the dashboard Home page's What's new section.
- */
- WhatsNewLaunch: {
- /** Description */
- description: string;
- /** Href */
- href: string;
- /** Icon */
- icon: string;
- /**
- * Published On
- * Format: date
- */
- published_on: string;
- /** Title */
- title: string;
- };
- /** WhatsNewResponse */
- WhatsNewResponse: {
- /** Launches */
- launches: components["schemas"]["WhatsNewLaunch"][];
- };
/** WorkerRegistryEntry */
WorkerRegistryEntry: {
/** Name */
@@ -62853,6 +63063,24 @@ export interface operations {
};
};
};
+ websocket_live_sessions_websocket_endpoint_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description WebSocket Protocol Switched */
+ 101: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
login_login_post: {
parameters: {
query?: never;
@@ -65464,6 +65692,24 @@ export interface operations {
};
};
};
+ websocket_live_sessions_websocket_endpoint_get_3: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description WebSocket Protocol Switched */
+ 101: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
websocket_realtime_websocket_endpoint_get_3: {
parameters: {
query?: never;
@@ -68748,26 +68994,6 @@ export interface operations {
};
};
};
- get_public_whats_new_public_whats_new_get: {
- parameters: {
- query?: never;
- header?: never;
- path?: never;
- cookie?: never;
- };
- requestBody?: never;
- responses: {
- /** @description Successful Response */
- 200: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- "application/json": components["schemas"]["WhatsNewResponse"];
- };
- };
- };
- };
async_queue_request_queue_chat_completions_post: {
parameters: {
query?: {
@@ -73290,6 +73516,147 @@ export interface operations {
};
};
};
+ export_telemetry_reports_telemetry_reports_get: {
+ parameters: {
+ query?: {
+ /** @description window_end of the last report already exported */
+ after?: number;
+ /** @description id of the last report already exported */
+ after_id?: string;
+ limit?: number;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["TelemetryReportsResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ get_telemetry_settings_telemetry_settings_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["TelemetrySettingsResponse"];
+ };
+ };
+ };
+ };
+ update_telemetry_settings_telemetry_settings_put: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["TelemetrySettingsUpdate"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["TelemetrySettingsResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ record_ui_event_telemetry_ui_events_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["UIEventBody"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UIEventAccepted"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ ui_events_enabled_telemetry_ui_events_enabled_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UIEventsEnabledResponse"];
+ };
+ };
+ };
+ };
test_endpoint_test_get: {
parameters: {
query?: never;
@@ -77720,6 +78087,24 @@ export interface operations {
};
};
};
+ websocket_live_sessions_websocket_endpoint_get_2: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description WebSocket Protocol Switched */
+ 101: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
ingest_otlp_traces_v1_logs_post: {
parameters: {
query?: never;
diff --git a/ui/litellm-dashboard/src/lib/telemetry/uiEvents.test.ts b/ui/litellm-dashboard/src/lib/telemetry/uiEvents.test.ts
new file mode 100644
index 00000000000..bab06af70f7
--- /dev/null
+++ b/ui/litellm-dashboard/src/lib/telemetry/uiEvents.test.ts
@@ -0,0 +1,58 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { queryClient } from "@/contexts/ReactQueryProvider";
+import { backgroundFetchClient } from "@/lib/http/api";
+import { pageNavigationQuery, recordUiEvent } from "./uiEvents";
+
+const enabledResponse = (enabled: boolean) => ({ data: { enabled }, response: new Response() });
+
+describe("recordUiEvent", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ queryClient.clear();
+ });
+
+ it("sends nothing while the proxy says page navigation is off", async () => {
+ vi.spyOn(backgroundFetchClient, "GET").mockResolvedValue(enabledResponse(false) as never);
+ const post = vi
+ .spyOn(backgroundFetchClient, "POST")
+ .mockResolvedValue({ data: undefined, response: new Response() });
+
+ await recordUiEvent({ page: "teams", action: "view" });
+
+ expect(post).not.toHaveBeenCalled();
+ });
+
+ it("asks the proxy once per window and posts every event while page navigation is on", async () => {
+ const get = vi.spyOn(backgroundFetchClient, "GET").mockResolvedValue(enabledResponse(true) as never);
+ const post = vi
+ .spyOn(backgroundFetchClient, "POST")
+ .mockResolvedValue({ data: undefined, response: new Response() });
+
+ await recordUiEvent({ page: "teams", action: "view" });
+ await recordUiEvent({ page: "teams", action: "click", target: "tab=members" });
+
+ expect(get).toHaveBeenCalledOnce();
+ expect(post).toHaveBeenLastCalledWith("/telemetry/ui_events", {
+ body: { page: "teams", action: "click", target: "tab=members" },
+ });
+ expect(post).toHaveBeenCalledTimes(2);
+ });
+
+ it("asks the proxy again once the cached answer is older than the report window", async () => {
+ vi.useFakeTimers();
+ const get = vi.spyOn(backgroundFetchClient, "GET").mockResolvedValue(enabledResponse(false) as never);
+
+ await recordUiEvent({ page: "teams", action: "view" });
+ vi.advanceTimersByTime(pageNavigationQuery.staleTime + 1);
+ await recordUiEvent({ page: "teams", action: "view" });
+
+ vi.useRealTimers();
+ expect(get).toHaveBeenCalledTimes(2);
+ });
+
+ it("swallows a failed check or post so telemetry never breaks the page", async () => {
+ vi.spyOn(backgroundFetchClient, "GET").mockRejectedValue(new Error("offline"));
+
+ await expect(recordUiEvent({ page: "teams", action: "view" })).resolves.toBeUndefined();
+ });
+});
diff --git a/ui/litellm-dashboard/src/lib/telemetry/uiEvents.ts b/ui/litellm-dashboard/src/lib/telemetry/uiEvents.ts
new file mode 100644
index 00000000000..8560852ebd7
--- /dev/null
+++ b/ui/litellm-dashboard/src/lib/telemetry/uiEvents.ts
@@ -0,0 +1,24 @@
+import { queryClient } from "@/contexts/ReactQueryProvider";
+import { backgroundFetchClient } from "@/lib/http/api";
+import type { components } from "@/lib/http/schema";
+
+export type UiEvent = components["schemas"]["UIEventBody"];
+
+export const TELEMETRY_WINDOW_MS = 300_000;
+
+export const pageNavigationQuery = {
+ queryKey: ["telemetry", "pageNavigationEnabled"],
+ queryFn: async (): Promise => {
+ const { data } = await backgroundFetchClient.GET("/telemetry/ui_events/enabled");
+ return data?.enabled ?? false;
+ },
+ staleTime: TELEMETRY_WINDOW_MS,
+ retry: false,
+} as const;
+
+export const recordUiEvent = (event: UiEvent): Promise =>
+ queryClient
+ .fetchQuery(pageNavigationQuery)
+ .then((enabled) => (enabled ? backgroundFetchClient.POST("/telemetry/ui_events", { body: event }) : undefined))
+ .then(() => undefined)
+ .catch(() => undefined);
diff --git a/uv.lock b/uv.lock
index 471460a32b5..05331266b3e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -4500,7 +4500,7 @@ wheels = [
[[package]]
name = "litellm"
-version = "1.106.0"
+version = "1.107.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -4996,12 +4996,12 @@ provides-extras = ["test"]
[[package]]
name = "litellm-enterprise"
-version = "0.1.76"
+version = "0.1.77"
source = { editable = "enterprise" }
[[package]]
name = "litellm-proxy-extras"
-version = "0.4.108"
+version = "0.4.109"
source = { editable = "litellm-proxy-extras" }
dependencies = [
{ name = "psycopg" },