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/DashboardHeader.integration.test.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx
index cb58670c20a..d899d032395 100644
--- a/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx
@@ -59,7 +59,7 @@ async function openTools() {
-
+
,
diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx
index 6c64d09009c..41d35bd40b7 100644
--- a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx
+++ b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx
@@ -35,7 +35,7 @@ vi.mock("@/components/liteadmin/LiteAdmin", () => ({ default: () => Lite
const renderDashboardHeader = () =>
render(
-
+
,
);
@@ -133,7 +133,7 @@ describe("DashboardHeader breadcrumb", () => {
state.isDesktop = true;
rerender(
-
+
,
);
expect(screen.queryByRole("dialog", { name: "Gateway tools" })).not.toBeInTheDocument();
@@ -141,7 +141,7 @@ describe("DashboardHeader breadcrumb", () => {
state.isDesktop = false;
rerender(
-
+
,
);
expect(screen.queryByRole("dialog", { name: "Gateway tools" })).not.toBeInTheDocument();
diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.tsx
index 7580f8b69c0..77e44a2ff9b 100644
--- a/ui/litellm-dashboard/src/components/DashboardHeader.tsx
+++ b/ui/litellm-dashboard/src/components/DashboardHeader.tsx
@@ -31,7 +31,13 @@ import { CommandPaletteTrigger } from "@/components/CommandPalette/CommandPalett
// Top bar for the dashboard shell. Sits only over the content column (the brand
// lives in the sidebar header); mirrors the design's breadcrumb-left / tools-right layout.
-export function DashboardHeader({ navigationTrigger }: { navigationTrigger?: ReactNode }) {
+export function DashboardHeader({
+ accessToken,
+ navigationTrigger,
+}: {
+ accessToken: string | null;
+ navigationTrigger?: ReactNode;
+}) {
const { title } = getBreadcrumb(usePathname());
const isDesktop = useMediaQuery("(min-width: 768px)", { initializeWithValue: false });
const [mobileToolsOpen, setMobileToolsOpen] = useState(false);
@@ -78,11 +84,11 @@ export function DashboardHeader({ navigationTrigger }: { navigationTrigger?: Rea
{!hideCommunityLinks && }
-
+
-
+
}>
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/Navbar/NotificationsBell/NotificationsBell.test.tsx b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx
index 8894f2a714b..e0c428a94a0 100644
--- a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx
+++ b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx
@@ -1,69 +1,175 @@
import { renderWithProviders, screen } from "../../../../tests/test-utils";
-import { NotificationsBell, AUTO_ROUTER_DOCS_URL } from "./NotificationsBell";
+import { AUTO_ROUTER_DOCS_URL, NotificationsBell, NotificationsBellView } from "./NotificationsBell";
+import type { LatestReleaseInfo } from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo";
import React from "react";
import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails", () => ({
+ useHealthReadinessDetails: vi.fn(),
+}));
+vi.mock("@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo", () => ({
+ useLatestReleaseInfo: vi.fn(),
+}));
+
+import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
+import { useLatestReleaseInfo } from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo";
+
+const RELEASE: LatestReleaseInfo = {
+ version: "1.103.0",
+ new_features: 12,
+ bug_fixes: 30,
+ other_updates: 8,
+ release_url: "https://github.com/BerriAI/litellm/releases/tag/v1.103.0",
+};
+
+const bell = (unread: number) =>
+ screen.getByRole("button", { name: unread === 0 ? /^notifications$/i : `Notifications, ${unread} unread` });
+
+const announcement = (title: string) => screen.getByRole("article", { name: title });
+
+describe("NotificationsBellView", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ it("lists the new version notice above Auto Router when the proxy is behind the latest release", async () => {
+ const user = userEvent.setup();
+ renderWithProviders( );
+ await user.click(bell(2));
+
+ const titles = screen.getAllByRole("article").map((item) => item.getAttribute("aria-label"));
+ expect(titles).toEqual(["LiteLLM v1.103.0 is available", "LiteLLM Auto Router"]);
+ const upgrade = announcement("LiteLLM v1.103.0 is available");
+ expect(upgrade).toHaveTextContent("12 new features, 30 fixes, and 8 other updates");
+ expect(upgrade).toHaveTextContent("Your current version is v1.102.0");
+ const notes = screen.getByRole("link", { name: /^view release notes$/i });
+ expect(notes).toHaveAttribute("href", RELEASE.release_url);
+ expect(notes).toHaveAttribute("rel", "noopener noreferrer");
+ const docs = screen.getByRole("link", { name: /^read the docs$/i });
+ expect(docs).toHaveAttribute("href", AUTO_ROUTER_DOCS_URL);
+ expect(docs).toHaveAttribute("target", "_blank");
+ });
+
+ it("uses singular wording for counts of one", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+ ,
+ );
+ await user.click(bell(2));
+ expect(announcement("LiteLLM v1.103.0 is available")).toHaveTextContent("1 new feature, 1 fix, and 1 other update");
+ });
+
+ it.each([
+ ["the running version is unknown", undefined, RELEASE],
+ ["the latest release is unavailable", "1.102.0", null],
+ ["the proxy is up to date", "1.103.0", RELEASE],
+ ["the proxy is ahead of the latest release", "1.104.0-dev.1", RELEASE],
+ ])("lists only Auto Router when %s", async (_case, currentVersion, latestRelease) => {
+ const user = userEvent.setup();
+ renderWithProviders( );
+ await user.click(bell(1));
+ expect(screen.getAllByRole("article").map((item) => item.getAttribute("aria-label"))).toEqual([
+ "LiteLLM Auto Router",
+ ]);
+ });
+
+ it("marks each announcement read on its own and clears the unread dot once all are read", async () => {
+ const user = userEvent.setup();
+ renderWithProviders( );
+ await user.click(bell(2));
+ await user.click(screen.getAllByRole("button", { name: /^mark as read$/i })[0]);
+
+ expect(bell(1).querySelector("[data-slot=badge]")).not.toBeNull();
+ expect(screen.getAllByRole("button", { name: /^mark as read$/i })).toHaveLength(1);
+ expect(announcement("LiteLLM Auto Router")).toContainElement(
+ screen.getByRole("button", { name: /^mark as read$/i }),
+ );
+ await user.click(screen.getByRole("button", { name: /^mark as read$/i }));
+
+ expect(bell(0).querySelector("[data-slot=badge]")).toBeNull();
+ expect(screen.getAllByRole("article")).toHaveLength(2);
+ expect(screen.queryByRole("button", { name: /^mark as read$/i })).not.toBeInTheDocument();
+ });
+
+ it("keeps the new version notice read across remounts for the same release", async () => {
+ const user = userEvent.setup();
+ const first = renderWithProviders( );
+ await user.click(bell(2));
+ await user.click(screen.getAllByRole("button", { name: /^mark as read$/i })[0]);
+ first.unmount();
+
+ renderWithProviders( );
+ expect(bell(1)).toBeInTheDocument();
+ });
+
+ it("shows a newer release as unread after an older release was read", async () => {
+ const user = userEvent.setup();
+ const { rerender } = renderWithProviders(
+ ,
+ );
+ await user.click(bell(2));
+ await user.click(screen.getAllByRole("button", { name: /^mark as read$/i })[0]);
+ expect(bell(1)).toBeInTheDocument();
+
+ rerender( );
+ expect(bell(2)).toBeInTheDocument();
+ expect(announcement("LiteLLM v1.104.0 is available")).toContainElement(
+ screen.getAllByRole("button", { name: /^mark as read$/i })[0],
+ );
+ });
+
+ it("treats a release whose upgrade banner was dismissed as already read", () => {
+ localStorage.setItem("litellm:upgradeBannerDismissed:1.103.0", "true");
+ renderWithProviders( );
+ expect(bell(1)).toBeInTheDocument();
+ });
+
+ it("keeps Auto Router read when it was read before", async () => {
+ localStorage.setItem("litellmHideAutoRouterAnnouncement", "true");
+ const user = userEvent.setup();
+ renderWithProviders( );
+ await user.click(bell(0));
+ expect(announcement("LiteLLM Auto Router")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /^mark as read$/i })).not.toBeInTheDocument();
+ });
+
+ it("syncs sibling bells when one marks an announcement read", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+ <>
+
+
+ >,
+ );
+ const [bellA] = screen.getAllByRole("button", { name: "Notifications, 2 unread" });
+ await user.click(bellA);
+ await user.click(screen.getAllByRole("button", { name: /^mark as read$/i })[0]);
+
+ expect(screen.getAllByRole("button", { name: "Notifications, 1 unread" })).toHaveLength(2);
+ });
+});
describe("NotificationsBell", () => {
beforeEach(() => {
localStorage.clear();
});
- it("should open notifications with Auto Router details and docs link", async () => {
+ it("builds the new version notice from the proxy version and the latest release", async () => {
+ vi.mocked(useHealthReadinessDetails).mockReturnValue({
+ data: { status: "healthy", litellm_version: "1.102.0" },
+ } as ReturnType);
+ vi.mocked(useLatestReleaseInfo).mockReturnValue({ data: RELEASE } as ReturnType);
const user = userEvent.setup();
- renderWithProviders( );
- await user.click(screen.getByRole("button", { name: /^notifications$/i }));
- expect(screen.getByText(/^LiteLLM Auto Router$/i)).toBeInTheDocument();
- const docsBtn = screen.getByRole("link", { name: /^read the docs$/i });
- expect(docsBtn).toHaveAttribute("href", AUTO_ROUTER_DOCS_URL);
- expect(docsBtn).toHaveAttribute("target", "_blank");
- expect(docsBtn).toHaveAttribute("rel", "noopener noreferrer");
- });
-
- it("should offer mark as read when announcement is unread", async () => {
- const user = userEvent.setup();
- renderWithProviders( );
- await user.click(screen.getByRole("button", { name: /^notifications$/i }));
- expect(screen.getByRole("button", { name: /^mark as read$/i })).toBeInTheDocument();
- });
-
- it("should hide mark as read and persist after marking read", async () => {
- const user = userEvent.setup();
- renderWithProviders( );
- await user.click(screen.getByRole("button", { name: /^notifications$/i }));
- await user.click(screen.getByRole("button", { name: /^mark as read$/i }));
- expect(localStorage.getItem("litellmHideAutoRouterAnnouncement")).toBe("true");
- await user.click(screen.getByRole("button", { name: /^notifications$/i }));
- expect(screen.queryByRole("button", { name: /^mark as read$/i })).not.toBeInTheDocument();
- });
-
- it("should not show mark as read when previously dismissed", async () => {
- localStorage.setItem("litellmHideAutoRouterAnnouncement", "true");
- const user = userEvent.setup();
- renderWithProviders( );
- await user.click(screen.getByRole("button", { name: /^notifications$/i }));
- expect(screen.queryByRole("button", { name: /^mark as read$/i })).not.toBeInTheDocument();
- });
-
- it("should sync sibling instances when one is dismissed", async () => {
- const user = userEvent.setup();
- renderWithProviders(
- <>
-
-
-
-
-
-
- >,
- );
-
- // Both bells start unread → both render the "Mark as read" affordance once opened.
- const [bellA, bellB] = screen.getAllByRole("button", { name: /^notifications$/i });
- await user.click(bellA);
- await user.click(screen.getByRole("button", { name: /^mark as read$/i }));
-
- // Dismissing in bell A must also clear bell B without a remount.
- await user.click(bellB);
- expect(screen.queryByRole("button", { name: /^mark as read$/i })).not.toBeInTheDocument();
+ renderWithProviders( );
+ await user.click(bell(2));
+ expect(announcement("LiteLLM v1.103.0 is available")).toHaveTextContent("Your current version is v1.102.0");
});
});
diff --git a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx
index 8b9b7d1d0d2..4cb9f851153 100644
--- a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx
+++ b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx
@@ -1,66 +1,128 @@
"use client";
+import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
import {
- HIDE_AUTO_ROUTER_ANNOUNCEMENT_KEY,
- useHideAutoRouterAnnouncement,
-} from "@/app/(dashboard)/hooks/useHideAutoRouterAnnouncement";
-import { emitLocalStorageChange, setLocalStorageItem } from "@/utils/localStorageUtils";
+ type LatestReleaseInfo,
+ useLatestReleaseInfo,
+} from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo";
+import {
+ markAnnouncementRead,
+ useAnnouncementRead,
+ useUnreadAnnouncementCount,
+} from "@/app/(dashboard)/hooks/useAnnouncementRead";
import { Badge } from "@/components/ui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
-import { Popover, PopoverContent, PopoverDescription, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/cva.config";
+import { isNewerVersion } from "@/utils/versionUtils";
import { Bell } from "lucide-react";
-import React, { useState } from "react";
+import React, { Fragment } from "react";
export const AUTO_ROUTER_DOCS_URL = "https://docs.litellm.ai/docs/proxy/auto_routing";
-export const NotificationsBell: React.FC = () => {
- const hidden = useHideAutoRouterAnnouncement();
- const hasUnread = !hidden;
- const [open, setOpen] = useState(false);
+interface Announcement {
+ readKey: string;
+ title: string;
+ description: string;
+ linkLabel: string;
+ href: string;
+}
- const markDismissed = () => {
- setLocalStorageItem(HIDE_AUTO_ROUTER_ANNOUNCEMENT_KEY, "true");
- emitLocalStorageChange(HIDE_AUTO_ROUTER_ANNOUNCEMENT_KEY);
- setOpen(false);
+const AUTO_ROUTER_ANNOUNCEMENT: Announcement = {
+ readKey: "litellmHideAutoRouterAnnouncement",
+ title: "LiteLLM Auto Router",
+ description: "Route every request to the cheapest model that can handle it, no prompt changes needed.",
+ linkLabel: "Read the docs",
+ href: AUTO_ROUTER_DOCS_URL,
+};
+
+const plural = (count: number, singular: string, pluralForm: string): string =>
+ `${count} ${count === 1 ? singular : pluralForm}`;
+
+const describeRelease = ({ new_features, bug_fixes, other_updates }: LatestReleaseInfo): string =>
+ [
+ plural(new_features, "new feature", "new features"),
+ plural(bug_fixes, "fix", "fixes"),
+ `and ${plural(other_updates, "other update", "other updates")}`,
+ ].join(", ");
+
+const upgradeAnnouncement = (
+ currentVersion: string | null | undefined,
+ latestRelease: LatestReleaseInfo | null | undefined,
+): Announcement | null => {
+ if (!currentVersion || !latestRelease || !isNewerVersion(currentVersion, latestRelease.version)) {
+ return null;
+ }
+ return {
+ readKey: `litellm:upgradeBannerDismissed:${latestRelease.version}`,
+ title: `LiteLLM v${latestRelease.version} is available`,
+ description: `${describeRelease(latestRelease)}. Your current version is v${currentVersion}`,
+ linkLabel: "View release notes",
+ href: latestRelease.release_url,
};
+};
- const content = (
-
-
LiteLLM Auto Router
-
- Route every request to the cheapest model that can handle it, no prompt changes needed.
-
+const AnnouncementItem: React.FC<{ announcement: Announcement }> = ({ announcement }) => {
+ const read = useAnnouncementRead(announcement.readKey);
+ return (
+
+ {announcement.title}
+ {announcement.description}
-
+
);
+};
+
+interface NotificationsBellViewProps {
+ currentVersion: string | null | undefined;
+ latestRelease: LatestReleaseInfo | null | undefined;
+}
+
+export const NotificationsBellView: React.FC = ({ currentVersion, latestRelease }) => {
+ const upgrade = upgradeAnnouncement(currentVersion, latestRelease);
+ const announcements = upgrade ? [upgrade, AUTO_ROUTER_ANNOUNCEMENT] : [AUTO_ROUTER_ANNOUNCEMENT];
+ const unreadCount = useUnreadAnnouncementCount(announcements.map((announcement) => announcement.readKey));
return (
-
+
0 ? `Notifications, ${unreadCount} unread` : "Notifications"}
>
- {hasUnread ? : null}
+ {unreadCount > 0 ? : null}
- {content}
+
+ {announcements.map((announcement, index) => (
+
+ {index > 0 ? : null}
+
+
+ ))}
+
);
};
+
+export const NotificationsBell: React.FC<{ accessToken: string | null }> = ({ accessToken }) => {
+ const { data: healthData } = useHealthReadinessDetails(accessToken);
+ const { data: latestRelease } = useLatestReleaseInfo(accessToken);
+ return ;
+};
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 (
({
- useHealthReadinessDetails: vi.fn(),
-}));
-vi.mock("@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo", () => ({
- useLatestReleaseInfo: vi.fn(),
-}));
-
-import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
-import { useLatestReleaseInfo } from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo";
-
-const RELEASE: LatestReleaseInfo = {
- version: "1.103.0",
- new_features: 12,
- bug_fixes: 30,
- other_updates: 8,
- release_url: "https://github.com/BerriAI/litellm/releases/tag/v1.103.0",
-};
-
-describe("describeRelease", () => {
- it("lists features, fixes, and other updates in the agreed order", () => {
- expect(describeRelease(RELEASE)).toBe("12 new features, 30 fixes, and 8 other updates");
- });
-
- it("singularises counts of one", () => {
- const singularCounts = { ...RELEASE, new_features: 1, bug_fixes: 1, other_updates: 1 };
- expect(describeRelease(singularCounts)).toBe("1 new feature, 1 fix, and 1 other update");
- });
-});
-
-describe("UpgradeBannerView", () => {
- beforeEach(() => {
- localStorage.clear();
- });
-
- afterEach(() => {
- localStorage.clear();
- });
-
- it("renders nothing while either version is unknown", () => {
- const { container } = render( );
- expect(container).toBeEmptyDOMElement();
- const { container: noRelease } = render( );
- expect(noRelease).toBeEmptyDOMElement();
- });
-
- it("renders nothing when the running version is up to date or ahead", () => {
- const { container } = render( );
- expect(container).toBeEmptyDOMElement();
- const { container: ahead } = render( );
- expect(ahead).toBeEmptyDOMElement();
- });
-
- it("shows the latest version, the stat line, and the current version when behind", () => {
- render( );
- const alert = screen.getByRole("status");
- expect(alert).toHaveTextContent("The latest version is v1.103.0: 12 new features, 30 fixes, and 8 other updates");
- expect(alert).toHaveTextContent("Your current version is v1.102.0");
- expect(screen.getByRole("link", { name: "v1.103.0" })).toHaveAttribute("href", RELEASE.release_url);
- });
-
- it("dismissing hides the banner and keeps it hidden on remount for the same release", () => {
- const { unmount } = render( );
- fireEvent.click(screen.getByRole("button", { name: "Close" }));
- expect(screen.queryByRole("status")).not.toBeInTheDocument();
- unmount();
-
- const { container } = render( );
- expect(container).toBeEmptyDOMElement();
- });
-
- it("reappears once a newer release ships after a dismissal", () => {
- const { unmount } = render( );
- fireEvent.click(screen.getByRole("button", { name: "Close" }));
- unmount();
-
- render( );
- expect(screen.getByRole("status")).toHaveTextContent("The latest version is v1.104.0");
- });
-
- it("shows a newer release after the current one was dismissed without remounting", () => {
- const { rerender } = render( );
- fireEvent.click(screen.getByRole("button", { name: "Close" }));
- expect(screen.queryByRole("status")).not.toBeInTheDocument();
- rerender( );
- expect(screen.getByRole("status")).toHaveTextContent("The latest version is v1.104.0");
- });
-});
-
-describe("UpgradeBanner", () => {
- beforeEach(() => {
- localStorage.clear();
- });
-
- afterEach(() => {
- localStorage.clear();
- });
-
- it("feeds both hooks the access token and renders from their data", () => {
- const healthReadinessResult = {
- data: { litellm_version: "1.102.0" },
- } as Partial> as ReturnType;
- const latestReleaseResult = { data: RELEASE } as Partial> as ReturnType<
- typeof useLatestReleaseInfo
- >;
- vi.mocked(useHealthReadinessDetails).mockReturnValue(healthReadinessResult);
- vi.mocked(useLatestReleaseInfo).mockReturnValue(latestReleaseResult);
- render( );
- expect(useHealthReadinessDetails).toHaveBeenCalledWith("token");
- expect(useLatestReleaseInfo).toHaveBeenCalledWith("token");
- expect(screen.getByRole("status")).toHaveTextContent("The latest version is v1.103.0");
- });
-
- it("renders nothing when the release endpoint returns null", () => {
- const healthReadinessResult = {
- data: { litellm_version: "1.102.0" },
- } as Partial> as ReturnType;
- const latestReleaseResult = { data: null } as Partial> as ReturnType<
- typeof useLatestReleaseInfo
- >;
- vi.mocked(useHealthReadinessDetails).mockReturnValue(healthReadinessResult);
- vi.mocked(useLatestReleaseInfo).mockReturnValue(latestReleaseResult);
- const { container } = render( );
- expect(container).toBeEmptyDOMElement();
- });
-});
diff --git a/ui/litellm-dashboard/src/components/UpgradeBanner.tsx b/ui/litellm-dashboard/src/components/UpgradeBanner.tsx
deleted file mode 100644
index 101a6e3410d..00000000000
--- a/ui/litellm-dashboard/src/components/UpgradeBanner.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-"use client";
-
-import React, { useState } from "react";
-import { ArrowUpCircle, X } from "lucide-react";
-import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert";
-import { Button } from "@/components/ui/button";
-import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
-import {
- type LatestReleaseInfo,
- useLatestReleaseInfo,
-} from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo";
-import { getLocalStorageItem, setLocalStorageItem } from "@/utils/localStorageUtils";
-import { isNewerVersion } from "@/utils/versionUtils";
-
-const DISMISS_KEY_PREFIX = "litellm:upgradeBannerDismissed:";
-
-interface UpgradeBannerProps {
- accessToken: string | null;
-}
-
-interface UpgradeBannerViewProps {
- currentVersion: string | null | undefined;
- latestRelease: LatestReleaseInfo | null | undefined;
-}
-
-const plural = (count: number, singular: string, pluralForm: string): string =>
- `${count} ${count === 1 ? singular : pluralForm}`;
-
-export const describeRelease = ({ new_features, bug_fixes, other_updates }: LatestReleaseInfo): string =>
- [
- plural(new_features, "new feature", "new features"),
- plural(bug_fixes, "fix", "fixes"),
- `and ${plural(other_updates, "other update", "other updates")}`,
- ].join(", ");
-
-export const UpgradeBannerView: React.FC = ({ currentVersion, latestRelease }) => {
- const [dismissedVersion, setDismissedVersion] = useState(null);
-
- if (!currentVersion || !latestRelease || !isNewerVersion(currentVersion, latestRelease.version)) {
- return null;
- }
-
- const dismissKey = `${DISMISS_KEY_PREFIX}${latestRelease.version}`;
- if (dismissedVersion === latestRelease.version || getLocalStorageItem(dismissKey) === "true") {
- return null;
- }
-
- const handleClose = () => {
- setLocalStorageItem(dismissKey, "true");
- setDismissedVersion(latestRelease.version);
- };
-
- return (
-
-
-
- The latest version is{" "}
-
- v{latestRelease.version}
-
- : {describeRelease(latestRelease)}
-
- Your current version is v{currentVersion}
-
-
-
-
-
-
- );
-};
-
-export const UpgradeBanner: React.FC = ({ accessToken }) => {
- const { data: healthData } = useHealthReadinessDetails(accessToken);
- const { data: latestRelease } = useLatestReleaseInfo(accessToken);
- return ;
-};
diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx
index 9a6ec334b56..b811ab8903c 100644
--- a/ui/litellm-dashboard/src/components/leftnav.tsx
+++ b/ui/litellm-dashboard/src/components/leftnav.tsx
@@ -77,6 +77,7 @@ import {
isUserTeamAdminForAnyTeam,
rolesAllowedToViewWriteScopedPages,
rolesWithWriteAccess,
+ proxyAdminTierRoles,
} from "../utils/roles";
import BetaBadge from "./BetaBadge";
import SidebarAccountMenu from "./SidebarAccountMenu/SidebarAccountMenu";
@@ -464,6 +465,13 @@ const menuGroups: MenuGroup[] = [
roles: all_admin_roles,
},
{ key: "ui-theme", page: "ui-theme", label: "UI Theme", icon: , 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/navbar.test.tsx b/ui/litellm-dashboard/src/components/navbar.test.tsx
index 9d80a89f83e..b8c87a3145e 100644
--- a/ui/litellm-dashboard/src/components/navbar.test.tsx
+++ b/ui/litellm-dashboard/src/components/navbar.test.tsx
@@ -143,7 +143,7 @@ describe("Navbar", () => {
it("should render without crashing", () => {
renderWithProviders( );
- expect(screen.getByRole("button", { name: /^notifications$/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /^notifications/i })).toBeInTheDocument();
expect(screen.getByText("Docs")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /open account menu/i })).toBeInTheDocument();
});
@@ -266,7 +266,7 @@ describe("Navbar", () => {
renderWithProviders( );
expect(screen.queryByRole("button", { name: /open account menu/i })).not.toBeInTheDocument();
- expect(screen.queryByRole("button", { name: /^notifications$/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /^notifications/i })).not.toBeInTheDocument();
});
it("should handle hide new feature indicators toggle", async () => {
diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx
index 8bc9b06969e..4e99d8e016f 100644
--- a/ui/litellm-dashboard/src/components/navbar.tsx
+++ b/ui/litellm-dashboard/src/components/navbar.tsx
@@ -149,7 +149,7 @@ const Navbar: React.FC = ({
-
+
diff --git a/ui/litellm-dashboard/src/components/organisms/createKeyOwnerAccess.test.ts b/ui/litellm-dashboard/src/components/organisms/createKeyOwnerAccess.test.ts
new file mode 100644
index 00000000000..204a2a8fa86
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/organisms/createKeyOwnerAccess.test.ts
@@ -0,0 +1,164 @@
+import { describe, expect, it } from "vitest";
+
+import type { Member } from "../networking";
+import {
+ administersTeam,
+ anotherUserScope,
+ effectiveKeyOwner,
+ isTeamMemberOwner,
+ teamMemberOwnerOptions,
+ teamMemberOwnerPlaceholder,
+ type TeamMembers,
+} from "./createKeyOwnerAccess";
+
+const VIEWER = "viewer";
+
+const member = (user_id: string | null, role: string, user_email: string | null = null): Member => ({
+ user_id,
+ role,
+ user_email,
+});
+
+const administered: TeamMembers & { team_id: string } = {
+ team_id: "administered",
+ members_with_roles: [member(VIEWER, "admin"), member("alice", "user", "alice@example.com"), member("bob", "user")],
+};
+
+const joined: TeamMembers & { team_id: string } = {
+ team_id: "joined",
+ members_with_roles: [member(VIEWER, "user"), member("carol", "admin", "carol@example.com")],
+};
+
+const foreign: TeamMembers & { team_id: string } = {
+ team_id: "foreign",
+ members_with_roles: [member("dave", "admin")],
+};
+
+const viewer = {
+ userRole: "Internal User",
+ userId: VIEWER,
+ teamAdminEditableFields: ["create_member_keys"],
+ teams: [joined, administered],
+};
+
+describe("anotherUserScope", () => {
+ it("lets a team admin pick among team members once the proxy enables create_member_keys", () => {
+ expect(anotherUserScope(viewer)).toBe("team_members");
+ });
+
+ it.each([
+ ["nothing is enabled", []],
+ ["only other team admin permissions are enabled", ["max_budget", "member_key_budgets", "projects"]],
+ ])("hides Another User from a team admin when %s", (_label, teamAdminEditableFields) => {
+ expect(anotherUserScope({ ...viewer, teamAdminEditableFields })).toBe("none");
+ });
+
+ it.each([
+ ["only a plain member of their teams", [joined]],
+ ["an admin of no listed team", [foreign]],
+ ["in no team", []],
+ ["still waiting on the team list", null],
+ ])("hides Another User with the permission on from a user who is %s", (_label, teams) => {
+ expect(anotherUserScope({ ...viewer, teams })).toBe("none");
+ });
+
+ it("hides Another User when the session carries no user id", () => {
+ expect(anotherUserScope({ ...viewer, userId: null })).toBe("none");
+ });
+
+ it.each([
+ ["with the permission off and no teams", { teamAdminEditableFields: [], teams: [] }],
+ ["with the permission on and an administered team", {}],
+ ["before the team list has loaded", { teams: null }],
+ ])("lets a proxy admin pick any user %s", (_label, overrides) => {
+ expect(anotherUserScope({ ...viewer, userRole: "Admin", ...overrides })).toBe("any_user");
+ });
+});
+
+describe("effectiveKeyOwner", () => {
+ it.each(["any_user", "team_members"] as const)("keeps another_user for a viewer scoped to %s", (scope) => {
+ expect(effectiveKeyOwner("another_user", scope)).toBe("another_user");
+ });
+
+ it("falls back to you when another_user is requested by a viewer who may not pick one", () => {
+ expect(effectiveKeyOwner("another_user", "none")).toBe("you");
+ });
+
+ it.each(["you", "service_account", "agent"])(
+ "leaves %s alone for a viewer who may not pick another user",
+ (owner) => {
+ expect(effectiveKeyOwner(owner, "none")).toBe(owner);
+ },
+ );
+});
+
+describe("administersTeam", () => {
+ it("narrows a team list to the teams the viewer administers", () => {
+ const teams = [joined, administered, foreign];
+
+ expect(teams.filter((team) => administersTeam(team, VIEWER)).map((team) => team.team_id)).toStrictEqual([
+ "administered",
+ ]);
+ });
+
+ it.each([
+ ["no team", null],
+ ["a team with no member list", {}],
+ ["a team whose member list is null", { members_with_roles: null }],
+ ])("is false for %s", (_label, team) => {
+ expect(administersTeam(team, VIEWER)).toBe(false);
+ });
+});
+
+describe("teamMemberOwnerOptions", () => {
+ it("lists the selected team's other members, labelled by email when the team has one", () => {
+ expect(teamMemberOwnerOptions(administered, VIEWER)).toStrictEqual([
+ { value: "alice", label: "alice@example.com (alice)" },
+ { value: "bob", label: "bob" },
+ ]);
+ });
+
+ it("skips a member row that has no user id", () => {
+ const team = {
+ members_with_roles: [member(VIEWER, "admin"), member(null, "user", "pending@example.com"), member("bob", "user")],
+ };
+
+ expect(teamMemberOwnerOptions(team, VIEWER)).toStrictEqual([{ value: "bob", label: "bob" }]);
+ });
+
+ it.each([
+ ["no team is selected", null],
+ ["the selected team is one the viewer only belongs to", joined],
+ ["the selected team is one the viewer is not in", foreign],
+ ])("offers nobody when %s", (_label, team) => {
+ expect(teamMemberOwnerOptions(team, VIEWER)).toStrictEqual([]);
+ });
+});
+
+describe("teamMemberOwnerPlaceholder", () => {
+ const soloTeam = { members_with_roles: [member(VIEWER, "admin")] };
+
+ it.each([
+ ["asks for a team before one is selected", null, "Select a team first"],
+ ["says so when the viewer is the team's only member", soloTeam, "This team has no other members"],
+ ["asks for a member once the team has someone to pick", administered, "Select a member of the team"],
+ ])("%s", (_label, team, expected) => {
+ expect(teamMemberOwnerPlaceholder(teamMemberOwnerOptions(team, VIEWER), team)).toBe(expected);
+ });
+});
+
+describe("isTeamMemberOwner", () => {
+ it("keeps an owner who belongs to the selected team", () => {
+ expect(isTeamMemberOwner("alice", administered, VIEWER)).toBe(true);
+ });
+
+ it.each([
+ ["is in another team only", "carol", administered],
+ ["was picked before the team was cleared", "alice", null],
+ ["belongs to a team the viewer does not administer", "carol", joined],
+ ["is the viewer, who already has the You option", VIEWER, administered],
+ ["is not set", null, administered],
+ ])("rejects an owner who %s", (_label, ownerId, team) => {
+ expect(isTeamMemberOwner(ownerId, team, VIEWER)).toBe(false);
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/organisms/createKeyOwnerAccess.ts b/ui/litellm-dashboard/src/components/organisms/createKeyOwnerAccess.ts
new file mode 100644
index 00000000000..dcbebab7c25
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/organisms/createKeyOwnerAccess.ts
@@ -0,0 +1,59 @@
+import type { SearchSelectOption } from "@/components/shared/SearchSelect";
+import { TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION } from "@/components/team/teamAdminEditAccess";
+import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles";
+import type { Member } from "../networking";
+
+export type AnotherUserScope = "any_user" | "team_members" | "none";
+
+export interface TeamMembers {
+ readonly members_with_roles?: Member[] | null;
+}
+
+export interface KeyOwnerViewer {
+ readonly userRole: string | null;
+ readonly userId: string | null;
+ readonly teamAdminEditableFields: readonly string[];
+ readonly teams: readonly TeamMembers[] | null;
+}
+
+export const administersTeam = (team: TeamMembers | null, userId: string | null): boolean =>
+ userId !== null && isUserTeamAdminForSingleTeam(team?.members_with_roles ?? null, userId);
+
+export const anotherUserScope = ({
+ userRole,
+ userId,
+ teamAdminEditableFields,
+ teams,
+}: KeyOwnerViewer): AnotherUserScope => {
+ if (isProxyAdminRole(userRole ?? "")) return "any_user";
+ if (!teamAdminEditableFields.includes(TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION)) return "none";
+ return (teams ?? []).some((team) => administersTeam(team, userId)) ? "team_members" : "none";
+};
+
+export const effectiveKeyOwner = (requestedOwner: string, scope: AnotherUserScope): string =>
+ requestedOwner === "another_user" && scope === "none" ? "you" : requestedOwner;
+
+export const teamMemberOwnerOptions = (team: TeamMembers | null, viewerId: string | null): SearchSelectOption[] => {
+ if (!administersTeam(team, viewerId)) return [];
+ return (team?.members_with_roles ?? []).flatMap((member) =>
+ member.user_id && member.user_id !== viewerId
+ ? [
+ {
+ value: member.user_id,
+ label: member.user_email ? `${member.user_email} (${member.user_id})` : member.user_id,
+ },
+ ]
+ : [],
+ );
+};
+
+export const teamMemberOwnerPlaceholder = (
+ options: readonly SearchSelectOption[],
+ team: TeamMembers | null,
+): string => {
+ if (options.length > 0) return "Select a member of the team";
+ return team ? "This team has no other members" : "Select a team first";
+};
+
+export const isTeamMemberOwner = (ownerId: unknown, team: TeamMembers | null, viewerId: string | null): boolean =>
+ teamMemberOwnerOptions(team, viewerId).some((option) => option.value === ownerId);
diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx
index 63ed7e14b67..0fbe043c0d1 100644
--- a/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx
@@ -649,6 +649,59 @@ describe("CreateKey", () => {
expect(screen.queryByRole("radio", { name: "Another User" })).not.toBeInTheDocument();
});
+ describe("for a team admin", () => {
+ const administeredTeam = {
+ team_id: "team-admin-of",
+ team_alias: "Administered Team",
+ models: [],
+ members_with_roles: [
+ { user_id: "test-user-id", role: "admin" },
+ { user_id: "member-1", role: "user", user_email: "member@example.com" },
+ ],
+ };
+ const joinedTeam = {
+ team_id: "team-member-of",
+ team_alias: "Joined Team",
+ models: [],
+ members_with_roles: [
+ { user_id: "test-user-id", role: "user" },
+ { user_id: "outsider-1", role: "admin", user_email: "outsider@example.com" },
+ ],
+ };
+
+ beforeEach(() => {
+ state.authorized = { ...state.authorized, userRole: "Internal User" };
+ state.teams = [joinedTeam, administeredTeam];
+ });
+
+ it("hides the Another User option until the proxy enables create_member_keys", async () => {
+ state.uiSettings = { team_admin_editable_team_fields: ["max_budget"] };
+ await openModal({ teams: state.teams as unknown as Team[] });
+
+ expect(screen.queryByRole("radio", { name: "Another User" })).not.toBeInTheDocument();
+ });
+
+ it("creates a key for a member of an administered team without the proxy-wide user search", async () => {
+ state.uiSettings = { team_admin_editable_team_fields: ["create_member_keys"] };
+ await openModal({ teams: state.teams as unknown as Team[] });
+ await nameTheKey();
+ await userEvent.click(screen.getByRole("radio", { name: "Another User" }));
+
+ await userEvent.click(await screen.findByLabelText(/^Team/));
+ expect(await screen.findByRole("option", { name: /Administered Team/ })).toBeInTheDocument();
+ expect(screen.queryByRole("option", { name: /Joined Team/ })).not.toBeInTheDocument();
+ await userEvent.click(screen.getByRole("option", { name: /Administered Team/ }));
+
+ await userEvent.click(await screen.findByLabelText(/^User ID/));
+ expect(await screen.findAllByRole("option")).toHaveLength(1);
+ await userEvent.click(screen.getByRole("option", { name: "member@example.com (member-1)" }));
+ await submit();
+
+ expect(await createdPayload()).toMatchObject({ user_id: "member-1", team_id: "team-admin-of" });
+ expect(userFilterUICall).not.toHaveBeenCalled();
+ });
+ });
+
it("routes a service account through the service account endpoint and stamps the alias into metadata", async () => {
state.teams = [{ team_id: "team-1", team_alias: "Team One", models: [] }];
await openModal({ teams: state.teams as unknown as Team[] });
diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx
index 45245ff95b3..eb8c96a675a 100644
--- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx
+++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx
@@ -6,6 +6,7 @@ import { useTags } from "@/app/(dashboard)/hooks/tags/useTags";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useCan from "@/app/(dashboard)/hooks/useCan";
+import { parseTeamAdminEditableFields } from "@/components/team/teamAdminEditAccess";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
@@ -80,6 +81,14 @@ import {
import CreatedKeyDisplay from "../shared/CreatedKeyDisplay";
import NumericalInput from "../shared/numerical_input";
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
+import {
+ administersTeam,
+ anotherUserScope,
+ effectiveKeyOwner,
+ isTeamMemberOwner,
+ teamMemberOwnerOptions,
+ teamMemberOwnerPlaceholder,
+} from "./createKeyOwnerAccess";
import { buildKeyCreatePayload, type KeyCreateInput } from "./createKeyPayload";
import { simplifyKeyGenerateError } from "./utils";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@@ -248,7 +257,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp
const [apiKey, setApiKey] = useState(null);
const [userModels, setUserModels] = useState([]);
const [modelsToPick, setModelsToPick] = useState([]);
- const [keyOwner, setKeyOwner] = useState("you");
+ const [requestedKeyOwner, setKeyOwner] = useState("you");
const [hasPrefilled, setHasPrefilled] = useState(false);
const [pendingPrefillModels, setPendingPrefillModels] = useState(null);
const [guardrailsList, setGuardrailsList] = useState([]);
@@ -279,6 +288,13 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp
const [agentsList, setAgentsList] = useState<{ agent_id: string; agent_name: string }[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState(null);
const selectedModels: string[] = (useWatch({ control: form.control, name: "models" }) as string[] | undefined) ?? [];
+ const teamAdminEditableFields = parseTeamAdminEditableFields(uiSettingsData?.values);
+ const keyOwnerViewer = { userRole, userId: userID, teamAdminEditableFields, teams };
+ const ownerScope = anotherUserScope(keyOwnerViewer);
+ const keyOwner = effectiveKeyOwner(requestedKeyOwner, ownerScope);
+ const ownerLimitedToTeamMembers = keyOwner === "another_user" && ownerScope === "team_members";
+ const memberOwnerOptions = teamMemberOwnerOptions(selectedCreateKeyTeam, userID);
+ const ownerRequiresTeam = keyOwner === "service_account" || ownerLimitedToTeamMembers;
const handleCancel = () => {
setIsModalVisible(false);
setApiKey(null);
@@ -383,14 +399,8 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp
// Apply prefill data if provided
if (prefillData) {
- // Set key owner (owned_by) - validate that "another_user" is only allowed for Admin
if (prefillData.owned_by) {
- if (prefillData.owned_by === "another_user" && userRole !== "Admin") {
- // Ignore invalid owned_by for non-admin users, fall back to default
- setKeyOwner("you");
- } else {
- setKeyOwner(prefillData.owned_by);
- }
+ setKeyOwner(prefillData.owned_by);
}
// Set team - find the team by ID and set it (only if team exists in user's teams)
@@ -616,6 +626,23 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp
}
};
+ const changeKeyOwner = (owner: string) => {
+ setKeyOwner(owner);
+ const limitsOwnerToTeamMembers = owner === "another_user" && ownerScope === "team_members";
+ if (limitsOwnerToTeamMembers && selectedCreateKeyTeam && !administersTeam(selectedCreateKeyTeam, userID)) {
+ selectTeam(null);
+ form.setValue("team_id", null);
+ }
+ };
+
+ useEffect(() => {
+ if (!ownerLimitedToTeamMembers) return;
+ const owner = form.getValues("user_id");
+ if (owner != null && !isTeamMemberOwner(owner, selectedCreateKeyTeam, userID)) {
+ form.setValue("user_id", null);
+ }
+ }, [ownerLimitedToTeamMembers, selectedCreateKeyTeam, userID, form]);
+
const changeProject = (write: FieldWrite) => (projectId: string | null) => {
write(projectId);
if (!projectId) {
@@ -650,6 +677,68 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp
}
};
+ const memberOwnerPlaceholder = teamMemberOwnerPlaceholder(memberOwnerOptions, selectedCreateKeyTeam);
+ const requiredTeamHelp = ownerLimitedToTeamMembers
+ ? "required, the key's owner must be a member of this team"
+ : "required";
+ const teamFieldHelp = ownerRequiresTeam ? requiredTeamHelp : "";
+ const ownerField = keyOwner === "another_user" && (
+
+ User ID{" "}
+
+
+
+
+ }
+ name="user_id"
+ className="mt-4"
+ required
+ rules={requiredRule(
+ keyOwner === "another_user",
+ `Please input the user ID of the user you are assigning the key to`,
+ )}
+ >
+ {(control) =>
+ ownerLimitedToTeamMembers ? (
+
+ ) : (
+
+
+
+
setIsCreateUserModalVisible(true)}>
+ Create User
+
+
+
Search by email or user ID to find users
+
+ )
+ }
+
+ );
+
return (
{userRole && rolesWithWriteAccess.includes(userRole) && (
@@ -679,7 +768,7 @@ const CreateKey: React.FC
= ({ team, teams, data, addKey, autoOp
setKeyOwner(String(value))}
+ onValueChange={(value: unknown) => changeKeyOwner(String(value))}
>
@@ -689,7 +778,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp
Service Account
- {userRole === "Admin" && (
+ {ownerScope !== "none" && (
Another User
@@ -702,50 +791,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp
- {keyOwner === "another_user" && (
-
- User ID{" "}
-
-
-
-
- }
- name="user_id"
- className="mt-4"
- required
- rules={requiredRule(
- keyOwner === "another_user",
- `Please input the user ID of the user you are assigning the key to`,
- )}
- >
- {(control) => (
-
-
-
-
setIsCreateUserModalVisible(true)}>
- Create User
-
-
-
Search by email or user ID to find users
-
- )}
-
- )}
+ {!ownerLimitedToTeamMembers && ownerField}
{keyOwner === "agent" && (
@@ -803,9 +849,14 @@ const CreateKey: React.FC
= ({ team, teams, data, addKey, autoOp
}
name="team_id"
className="mt-4"
- required={keyOwner === "service_account"}
- rules={requiredRule(keyOwner === "service_account", "Please select a team for the service account")}
- help={keyOwner === "service_account" ? "required" : ""}
+ required={ownerRequiresTeam}
+ rules={requiredRule(
+ ownerRequiresTeam,
+ ownerLimitedToTeamMembers
+ ? "Please select the team the key's owner belongs to"
+ : "Please select a team for the service account",
+ )}
+ help={teamFieldHelp}
>
{(control) => (
= ({ team, teams, data, addKey, autoOp
disabled={selectedProjectId !== null}
organizationId={selectedOrganizationId}
onTeamSelect={selectTeam}
+ filterTeam={ownerLimitedToTeamMembers ? (team) => administersTeam(team, userID) : undefined}
/>
)}
+ {ownerLimitedToTeamMembers && ownerField}
{enableProjectsUI && (
= {
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}
>
{
["max_budget", "Max Budget (USD)"],
["raise_max_budget", "Raise the team's max budget"],
["projects", "Create and update projects"],
+ ["create_member_keys", "Create keys for team members"],
])("names %s the way the team settings form does", (field, label) => {
expect(teamAdminFieldLabel(field)).toBe(label);
});
diff --git a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts
index cd24d6bdff0..1fed045292b 100644
--- a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts
+++ b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts
@@ -5,6 +5,8 @@ export const TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING = "team_admin_editable_team
export const TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION = "raise_max_budget";
+export const TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION = "create_member_keys";
+
export const TEAM_ADMIN_EDITING_DISABLED_TITLE = "Team admins cannot edit team settings on this proxy";
export const TEAM_ADMIN_EDITING_DISABLED_DESCRIPTION =
"Ask a proxy admin to enable fields under Settings > UI > Team admin editable fields.";
@@ -57,6 +59,7 @@ const TEAM_ADMIN_FIELD_LABELS: ReadonlyMap = new Map([
[TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION, "Raise the team's max budget"],
["projects", "Create and update projects"],
["member_key_budgets", "Update budgets on team members' keys"],
+ [TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION, "Create keys for team members"],
]);
export const teamAdminFieldLabel = (field: string): string => TEAM_ADMIN_FIELD_LABELS.get(field) ?? field;
diff --git a/ui/litellm-dashboard/src/contexts/ReactQueryProvider.tsx b/ui/litellm-dashboard/src/contexts/ReactQueryProvider.tsx
index ddcdbecc9c5..b2fada0e8bf 100644
--- a/ui/litellm-dashboard/src/contexts/ReactQueryProvider.tsx
+++ b/ui/litellm-dashboard/src/contexts/ReactQueryProvider.tsx
@@ -10,7 +10,7 @@ const isRetryable = (error: unknown): boolean => !(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 3a509fdd395..2cc91879f7e 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -2449,6 +2449,7 @@ export interface paths {
* - homepage: Plugin homepage URL (optional)
* - keywords: Search keywords (optional)
* - category: Plugin category (optional)
+ * - installation_preference: 'available', 'auto_install', or 'required' (optional)
*
* Returns:
* Registration status (action is always "created") and plugin information.
@@ -2515,6 +2516,7 @@ export interface paths {
* - homepage: Plugin homepage URL (optional)
* - keywords: Search keywords (optional)
* - category: Plugin category (optional)
+ * - installation_preference: 'available', 'auto_install', or 'required' (optional)
*
* Returns:
* Update status (action is always "updated") and plugin information.
@@ -6145,6 +6147,9 @@ export interface paths {
* @description Cache activity for the Admin UI cache dashboard, aggregated per call_type:
* cache hits vs successful LLM API requests vs failed requests, plus totals
* for the stat cards and the available key-alias/model filter options.
+ *
+ * Admin callers see proxy-wide activity; all other callers see only their own
+ * spend logs and those of teams they may view, exactly like ``/spend/logs/ui``.
*/
get: operations["get_global_activity_global_activity_cache_hits_get"];
put?: never;
@@ -8347,7 +8352,7 @@ export interface paths {
* - key_alias: Optional[str] - User defined key alias
* - key: Optional[str] - User defined key value. Must start with 'sk-' and be at least 16 characters long. If not set, a 16-digit unique sk-key is created for you.
* - team_id: Optional[str] - The team id of the key
- * - user_id: Optional[str] - The user id of the key
+ * - user_id: Optional[str] - The user id of the key. Non-admin callers may only pass their own id, except a team admin creating a key in a team they administer for another member of that team, when a proxy admin has added `create_member_keys` to `team_admin_editable_team_fields`. That member must have the `internal_user` or `internal_user_viewer` role, and the key is always an `llm_api` key.
* - agent_id: Optional[str] - The agent id associated with the key.
* - organization_id: Optional[str] - The organization id of the key. If not set, and team_id is set, the organization id will be the same as the team id. If conflict, an error will be raised.
* - project_id: Optional[str] - The project id of the key. When set, models and max_budget are validated against the project's limits.
@@ -9142,6 +9147,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 +10847,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 +13499,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 +13706,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 +17501,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 +20398,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 +27181,11 @@ export interface components {
date: string;
/** Description */
description: string;
+ /**
+ * Tags
+ * @default []
+ */
+ tags: string[];
/** Title */
title: string;
/** Url */
@@ -29601,7 +29731,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;
/**
@@ -36624,6 +36754,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'.
@@ -37631,6 +37766,8 @@ export interface components {
reasoning_content?: string | null;
/** Reasoning Items */
reasoning_items?: components["schemas"]["ChatCompletionReasoningItem"][] | null;
+ /** Refusal */
+ refusal?: string | null;
/**
* Role
* @enum {string}
@@ -40106,6 +40243,8 @@ export interface components {
homepage?: string | null;
/** Id */
id: string;
+ /** Installation Preference */
+ installation_preference?: ("available" | "auto_install" | "required") | null;
/** Keywords */
keywords?: string[] | null;
/** Name */
@@ -40141,6 +40280,11 @@ export interface components {
* @description Plugin unique ID
*/
id: string;
+ /**
+ * Installation Preference
+ * @description Plugin installation preference
+ */
+ installation_preference?: ("available" | "auto_install" | "required") | null;
/**
* Name
* @description Plugin name
@@ -41559,6 +41703,11 @@ export interface components {
* @description Plugin homepage URL
*/
homepage?: string | null;
+ /**
+ * Installation Preference
+ * @description Emitted as installationPreference on this plugin's marketplace.json entry. Needs an archive source, the only plugins Claude Desktop lists from a marketplace.json URL, and 'auto_install' or 'required' also need its sha256. Claude Desktop honors the mark only when the archive and marketplace.json share its inference gateway origin
+ */
+ installation_preference?: ("available" | "auto_install" | "required") | null;
/**
* Keywords
* @description Search keywords
@@ -45267,6 +45416,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
@@ -46086,6 +46245,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.
@@ -47071,6 +47295,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 */
@@ -47614,6 +47865,11 @@ export interface components {
* @description Plugin homepage URL
*/
homepage?: string | null;
+ /**
+ * Installation Preference
+ * @description Emitted as installationPreference on this plugin's marketplace.json entry. Needs an archive source, the only plugins Claude Desktop lists from a marketplace.json URL, and 'auto_install' or 'required' also need its sha256. Claude Desktop honors the mark only when the archive and marketplace.json share its inference gateway origin
+ */
+ installation_preference?: ("available" | "auto_install" | "required") | null;
/**
* Keywords
* @description Search keywords
@@ -49276,30 +49532,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 */
@@ -62825,6 +63057,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;
@@ -65436,6 +65686,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;
@@ -68720,26 +68988,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?: {
@@ -73262,6 +73510,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;
@@ -77692,6 +78081,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..922f00b9a1d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 3
+revision = 5
requires-python = ">=3.10, <3.15"
resolution-markers = [
"python_full_version >= '3.14'",
@@ -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" },
@@ -5532,7 +5532,7 @@ wheels = [
[[package]]
name = "mlflow"
-version = "3.15.0"
+version = "3.17.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -5559,16 +5559,17 @@ dependencies = [
{ name = "sqlalchemy" },
{ name = "waitress", marker = "sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/af/9f/249b80849aae0919aee3f590003419c394f0efe92ae98546ea626be86e1e/mlflow-3.15.0.tar.gz", hash = "sha256:c53d7933391fefda2ac0f6b942d1744a02b5b65da74a93cc125247f4c4da57c9", size = 10395873, upload-time = "2026-07-31T07:06:00.211Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/28/ea097011a17f49ad1b2f6371d9acd2d609f1d2ccd363c0f002cb26171b74/mlflow-3.17.0.tar.gz", hash = "sha256:7c1566b1aca61aec58520516df085948874c7956798dd6ae8ca17560ce06b089", size = 11146015, upload-time = "2026-10-07T06:07:42.286Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/0e/7a/3da40be70e7ca0c4d8c2f98edfcb275ef2898d8f4da565f55a9fff95fc78/mlflow-3.15.0-py3-none-any.whl", hash = "sha256:3ae54c7f91a6b98ae9360aaeda9b31eb7930571c2690491a7b550c84f795a709", size = 11188317, upload-time = "2026-07-31T07:05:57.492Z" },
+ { url = "https://files.pythonhosted.org/packages/74/4b/64533cd38fa188104011c028d04e056c58bc0a9f5059dbcce61079dd765b/mlflow-3.17.0-py3-none-any.whl", hash = "sha256:3954eaa998d4dd4ac8772894293a285bb5e3a428f6f9b37cac298e787423eafd", size = 11989296, upload-time = "2026-10-07T06:07:39.737Z" },
]
[[package]]
name = "mlflow-skinny"
-version = "3.15.0"
+version = "3.17.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
+ { name = "anyio" },
{ name = "cachetools" },
{ name = "click" },
{ name = "cloudpickle" },
@@ -5590,14 +5591,14 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uvicorn" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/53/a1/04489b8f10ec642cc5d16d8bcf0e3626cbe6b8924940b510de9e3cb37ac7/mlflow_skinny-3.15.0.tar.gz", hash = "sha256:6faad0bb5b0b8adc08550c6cd2ab5a87f9e7d7fe8814278e22b79f9cac99d943", size = 3034359, upload-time = "2026-07-31T07:04:58.359Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/18/5a/dea201b3cec8a3a1d97b042684113df54e79681f4bc051e807a13dd164b2/mlflow_skinny-3.17.0.tar.gz", hash = "sha256:e887675d9f5ab57ec70860247b1cd5682bb719f626c69979ca7c99b5439877df", size = 3472600, upload-time = "2026-10-07T05:46:44.897Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/28/ca/b0194962614876abb8b9a4d38815b4c6bf5c6f626b5dd3bcd321048755fc/mlflow_skinny-3.15.0-py3-none-any.whl", hash = "sha256:fc609ea5f7325a76d6f905489d8be73e85bcba0eef64144a763cf446de83192e", size = 3612199, upload-time = "2026-07-31T07:04:56.564Z" },
+ { url = "https://files.pythonhosted.org/packages/17/36/6c4cb2aa855061705f0295228654cfca564a0a2425d5e5484f187e4aa44d/mlflow_skinny-3.17.0-py3-none-any.whl", hash = "sha256:443c1e8cbf04edc71434754e0dae2846db91814b342c65bbc05b131c2a68b7ba", size = 4090651, upload-time = "2026-10-07T05:46:42.447Z" },
]
[[package]]
name = "mlflow-tracing"
-version = "3.15.0"
+version = "3.17.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cachetools" },
@@ -5609,9 +5610,9 @@ dependencies = [
{ name = "protobuf" },
{ name = "pydantic" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/7b/47/8a3c4f56199aadce644256684060df09f0af6ecaec460860657f2cb13b10/mlflow_tracing-3.15.0.tar.gz", hash = "sha256:e4e73243eed04d0771954b6fa17014dd21dabc67af17d4937fac2b62ec476e95", size = 1500398, upload-time = "2026-07-31T07:05:54.445Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/8b/8115b256c748855f1614909dcacac12c2fdb6de7e31759936ca36d92a43e/mlflow_tracing-3.17.0.tar.gz", hash = "sha256:6259dc0149bb681d6738c98f861f02db43c87c18e0a20a7ec9cbce1b7f386512", size = 1686449, upload-time = "2026-10-07T05:47:34.94Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/76/3c/5839690c3fe68f579074080832cba413f988249ddd350dcf7dfc3be1b874/mlflow_tracing-3.15.0-py3-none-any.whl", hash = "sha256:3762f654a858474c7988c0a2a5246765b173fa2b6a2bb8f035c115db1a8e3a6a", size = 1784776, upload-time = "2026-07-31T07:05:52.937Z" },
+ { url = "https://files.pythonhosted.org/packages/04/bf/b5b4d00ec9b48f0a735f5d514d0c5e2ee339a21d251de6f864cfe32dcdee/mlflow_tracing-3.17.0-py3-none-any.whl", hash = "sha256:8e5cb21bd53720f163098fe12a54e2cc5dff19e5b811889205ef10469248ee9e", size = 1978708, upload-time = "2026-10-07T05:47:33.381Z" },
]
[[package]]