From e4766973a0466e2cdb99ad243aac0081d2bce83b Mon Sep 17 00:00:00 2001 From: Ishaan Date: Fri, 23 Oct 2026 15:54:26 -0700 Subject: [PATCH] feat(ui): give Aria usage charts and PDF answer exports (#48094) * fix(ui): preserve Aria usage token breakdown and viewer model access * fix(ui): make Aria conversations easier to read and reply to * fix(ui): wrap long headings in assistant tables * feat(ui): simplify Aria and add usage charts with PDF answer export * fix(ui): retain full chart values in Aria PDF exports --- .../test_user_api_key_auth_request_flow.py | 135 +++- .../_components/HomePage.integration.test.tsx | 3 + .../components/chat_ui/ChatComposer.test.tsx | 20 + .../components/chat_ui/ChatComposer.tsx | 15 +- .../src/components/chat/ChatMessages.tsx | 13 +- .../liteadmin/AriaAnswer.integration.test.tsx | 146 ++++ .../src/components/liteadmin/AriaAnswer.tsx | 47 ++ .../liteadmin/LiteAdmin.integration.test.tsx | 648 ++++++++++-------- .../src/components/liteadmin/LiteAdmin.tsx | 216 ++---- .../liteadmin/LiteAdminConversation.tsx | 25 +- .../components/liteadmin/UsageAnswerChart.tsx | 78 +++ .../src/components/liteadmin/agent.test.ts | 164 ++++- .../src/components/liteadmin/agent.ts | 1 + .../components/liteadmin/answerExport.test.ts | 134 ++++ .../src/components/liteadmin/answerExport.ts | 238 +++++++ .../src/components/liteadmin/operations.ts | 48 +- .../components/liteadmin/toolResults.test.ts | 185 ++++- .../src/components/liteadmin/toolResults.ts | 37 +- .../components/liteadmin/usageChart.test.ts | 198 ++++++ .../src/components/liteadmin/usageChart.ts | 148 ++++ .../src/components/liteadmin/useLiteAdmin.ts | 15 +- .../shared/charts/bar_chart.test.tsx | 22 + .../components/shared/charts/bar_chart.tsx | 13 +- 23 files changed, 2022 insertions(+), 527 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/liteadmin/AriaAnswer.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/liteadmin/AriaAnswer.tsx create mode 100644 ui/litellm-dashboard/src/components/liteadmin/UsageAnswerChart.tsx create mode 100644 ui/litellm-dashboard/src/components/liteadmin/answerExport.test.ts create mode 100644 ui/litellm-dashboard/src/components/liteadmin/answerExport.ts create mode 100644 ui/litellm-dashboard/src/components/liteadmin/usageChart.test.ts create mode 100644 ui/litellm-dashboard/src/components/liteadmin/usageChart.ts diff --git a/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py b/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py index 4cdd7fefd28..108f7ce2983 100644 --- a/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py +++ b/tests/unit/proxy/auth/test_user_api_key_auth_request_flow.py @@ -11688,6 +11688,124 @@ def _alias_request(route: str, data: dict, content_type: str = "application/json return request +def _viewer_inference_key(monkeypatch: pytest.MonkeyPatch, team_id: str | None, *, spend: float = 0.25) -> str: + from litellm.proxy import proxy_server + from litellm.proxy.utils import ProxyLogging, hash_token + + key: Final = "sk-viewer-inference" + hashed_key: Final = hash_token(key) + cache: Final = UserApiKeyCache() + cache.set_cache( + key=hashed_key, + value=UserAPIKeyAuth( + token=hashed_key, + user_id="viewer", + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + team_id=team_id, + models=["gpt-6.1-sol"], + max_budget=1.0, + spend=spend, + ), + ) + cache.set_cache( + key="viewer", + value=LiteLLM_UserTable( + user_id="viewer", + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + user_email="viewer@example.com", + models=["gpt-6.1-sol"], + ), + ) + if team_id is not None: + cache.set_cache( + key=team_membership_reservation_cache_key(user_id="viewer", team_id=team_id), + value=NO_TEAM_MEMBERSHIP_SENTINEL, + ) + for name, value in { + **_proxy_attrs_for_centralized_checks(), + "prisma_client": MagicMock(), + "user_api_key_cache": cache, + "spend_counter_cache": DualCache(), + "proxy_logging_obj": ProxyLogging(user_api_key_cache=cache), + "llm_model_list": [], + }.items(): + monkeypatch.setattr(proxy_server, name, value) + return key + + +@pytest.mark.asyncio +@pytest.mark.parametrize("team_id", ["litellm-dashboard", None], ids=["dashboard-session", "virtual-key"]) +@pytest.mark.parametrize("route", ["/chat/completions", "/v1/chat/completions"]) +async def test_viewer_inference_uses_the_normal_key_authorization_path( + monkeypatch: pytest.MonkeyPatch, team_id: str | None, route: str +) -> None: + key: Final = _viewer_inference_key(monkeypatch, team_id) + request: Final = _alias_request(route, {"model": "gpt-6.1-sol", "messages": [{"role": "user", "content": "usage"}]}) + + result: Final = await user_api_key_auth(request=request, api_key=f"Bearer {key}") + + assert (result.user_id, result.user_role, result.team_id, result.models, result.max_budget) == ( + "viewer", + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + team_id, + ["gpt-6.1-sol"], + 1.0, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("team_id", ["litellm-dashboard", None], ids=["dashboard-session", "virtual-key"]) +async def test_viewer_inference_rejects_a_model_outside_the_key_allowlist( + monkeypatch: pytest.MonkeyPatch, team_id: str | None +) -> None: + key: Final = _viewer_inference_key(monkeypatch, team_id) + request: Final = _alias_request("/chat/completions", {"model": "blocked-model"}) + + with pytest.raises(ProxyException) as failure: + await user_api_key_auth(request=request, api_key=f"Bearer {key}") + + assert (failure.value.code, failure.value.type, failure.value.param) == ( + "403", + ProxyErrorTypes.key_model_access_denied, + "model", + ) + assert failure.value.message == ( + "The requested model 'blocked-model' is not available for this API key, or the model name is invalid. " + "Check the models available to you and try again." + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("team_id", ["litellm-dashboard", None], ids=["dashboard-session", "virtual-key"]) +@pytest.mark.parametrize("spend", [1.0, 1.25], ids=["at-limit", "over-limit"]) +async def test_viewer_inference_rejects_an_exhausted_key_budget( + monkeypatch: pytest.MonkeyPatch, team_id: str | None, spend: float +) -> None: + key: Final = _viewer_inference_key(monkeypatch, team_id, spend=spend) + request: Final = _alias_request("/chat/completions", {"model": "gpt-6.1-sol"}) + + with pytest.raises(ProxyException) as failure: + await user_api_key_auth(request=request, api_key=f"Bearer {key}") + + assert failure.value.type == ProxyErrorTypes.budget_exceeded + assert f"Current cost: {spend}, Max budget: 1.0" in failure.value.message + + +@pytest.mark.asyncio +@pytest.mark.parametrize("team_id", ["litellm-dashboard", None], ids=["dashboard-session", "virtual-key"]) +async def test_viewer_inference_access_does_not_allow_management_writes( + monkeypatch: pytest.MonkeyPatch, team_id: str | None +) -> None: + key: Final = _viewer_inference_key(monkeypatch, team_id) + request: Final = _alias_request("/team/new", {"team_alias": "forbidden"}) + + with pytest.raises(ProxyException) as failure: + await user_api_key_auth(request=request, api_key=f"Bearer {key}") + + assert failure.value.code == "403" + assert "proxy_admin_viewer" in failure.value.message + + async def _enforce_alias_access(token: UserAPIKeyAuth, data: dict, route: str, request, router: litellm.Router): from litellm.proxy.auth.user_api_key_auth import enforce_key_and_fallback_model_access @@ -12480,11 +12598,14 @@ async def test_lens_oauth2_validates_identity_without_inference_user_field( patch("litellm.proxy.proxy_server.master_key", "sk-fixture-master"), patch("litellm.proxy.proxy_server.prisma_client", None), patch("litellm.proxy.proxy_server.user_api_key_cache", DualCache()), - patch.dict(os.environ, { - "OAUTH_TOKEN_INFO_ENDPOINT": "https://oauth.fixture.test/introspect", - "OAUTH_CLIENT_ID": "fixture-client", - "OAUTH_CLIENT_SECRET": "fixture-client-secret", - }), + patch.dict( + os.environ, + { + "OAUTH_TOKEN_INFO_ENDPOINT": "https://oauth.fixture.test/introspect", + "OAUTH_CLIENT_ID": "fixture-client", + "OAUTH_CLIENT_SECRET": "fixture-client-secret", + }, + ), patch("httpx.AsyncClient.send", side_effect=token_info) as network, ): if active: @@ -12611,6 +12732,4 @@ def test_should_skip_budget_checks_spares_only_zero_spend_mcp_discovery( ) -> None: from litellm.proxy.auth.user_api_key_auth import _should_skip_budget_checks - assert ( - _should_skip_budget_checks(request_data=request_data, route=route, request=None, llm_router=None) is expected - ) + assert _should_skip_budget_checks(request_data=request_data, route=route, request=None, llm_router=None) is expected diff --git a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.integration.test.tsx index edde511d1cd..1891a580088 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.integration.test.tsx @@ -49,6 +49,9 @@ const requestUrl = (input: RequestInfo | URL): string => { }; const fakeFetch = (input: RequestInfo | URL) => { + if (requestUrl(input).endsWith("/models")) { + return Promise.resolve(Response.json({ data: [{ id: "claude-sonnet-5-5" }] })); + } if (requestUrl(input).includes("/model_group/info")) { return Promise.resolve(Response.json({ data: [{ model_group: "claude-sonnet-5-5", mode: "chat" }] })); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.test.tsx index da2e4debb3c..4903a431ff7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { ChatComposer } from "./ChatComposer"; @@ -111,4 +112,23 @@ describe("ChatComposer", () => { expect(screen.getByTestId("chat-composer-input")).toHaveFocus(); }); + + it.each([ + { label: "Find a model", control: }, + { + label: "Choose a model", + control: ( + + ), + }, + ])("should preserve focus when clicking the $label toolbar control", async ({ label, control }) => { + renderComposer({ tools: control }); + + await userEvent.setup().click(screen.getByLabelText(label)); + + expect(screen.getByLabelText(label)).toHaveFocus(); + expect(screen.getByPlaceholderText("Send a message")).not.toHaveFocus(); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.tsx index 5d2edbdfc94..c33b5e992fc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.tsx @@ -51,6 +51,14 @@ export function ChatComposer({ } }; + const handleToolbarClick = (event: React.MouseEvent) => { + const control = (event.target as HTMLElement).closest( + "button, input, select, textarea, a[href], [contenteditable='true'], [tabindex]", + ); + if (event.defaultPrevented || (control && event.currentTarget.contains(control))) return; + event.currentTarget.parentElement?.querySelector("[data-slot=input-group-control]")?.focus(); + }; + return (
{showSuggestions && suggestions.length > 0 && ( @@ -99,12 +107,7 @@ export function ChatComposer({ { - if ((event.target as HTMLElement).closest("button")) { - return; - } - event.currentTarget.parentElement?.querySelector("[data-slot=input-group-control]")?.focus(); - }} + onClick={handleToolbarClick} >
{tools}
diff --git a/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx b/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx index 5b331cc81bc..48ba5ddd0ae 100644 --- a/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx +++ b/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx @@ -70,14 +70,18 @@ function MarkdownCodeRenderer({ const markdownComponents: Components = { code: MarkdownCodeRenderer, pre: ({ node, ...props }) =>
,
+  h1: ({ node, ...props }) => 

, + h2: ({ node, ...props }) =>

, + h3: ({ node, ...props }) =>

, p: ({ node, ...props }) =>

, + blockquote: ({ node, ...props }) =>

, ul: ({ node, ...props }) =>
    , ol: ({ node, ...props }) =>
      , table: ({ node, ...props }) => , thead: ({ node, ...props }) => , tbody: ({ node, ...props }) => , tr: ({ node, ...props }) => , - th: ({ node, ...props }) => , + th: ({ node, ...props }) => , td: ({ node, ...props }) => , }; @@ -212,6 +216,7 @@ interface AssistantBubbleProps { isStreaming: boolean; isTypingIndicator: boolean; mcpEvents?: ChatMessage["mcpEvents"]; + actions?: React.ReactNode; } function AssistantBubble({ @@ -221,6 +226,7 @@ function AssistantBubble({ isStreaming, isTypingIndicator, mcpEvents, + actions, }: AssistantBubbleProps) { const [reasoningKey, setReasoningKey] = useState(0); const prevStreamingRef = useRef(isStreaming); @@ -271,7 +277,7 @@ function AssistantBubble({ {stoppedSuffix && [stopped]} - + {actions ?? } {mcpEvents && mcpEvents.length > 0 && (
      @@ -421,6 +427,7 @@ interface Props { interface ChatMessageContentProps { message: ChatMessage; + actions?: React.ReactNode; allowImages?: boolean; isStreaming?: boolean; isLastMessage?: boolean; @@ -433,6 +440,7 @@ export function ChatMessageContent({ isStreaming = false, isLastMessage = true, onEditMessage, + actions, }: ChatMessageContentProps) { if (message.role === "user") { return ; @@ -450,6 +458,7 @@ export function ChatMessageContent({ isStreaming={isStreaming} isTypingIndicator={isLastMessage && isStreaming && message.content === ""} mcpEvents={message.mcpEvents} + actions={actions} /> ); } diff --git a/ui/litellm-dashboard/src/components/liteadmin/AriaAnswer.integration.test.tsx b/ui/litellm-dashboard/src/components/liteadmin/AriaAnswer.integration.test.tsx new file mode 100644 index 00000000000..6040ae1f115 --- /dev/null +++ b/ui/litellm-dashboard/src/components/liteadmin/AriaAnswer.integration.test.tsx @@ -0,0 +1,146 @@ +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChatMessage } from "@/components/chat/types"; +import { toast } from "@/lib/toast"; +import { AriaAnswer } from "./AriaAnswer"; +import { createAriaAnswerDocument, exportAriaAnswer } from "./answerExport"; +import type { UsageChart } from "./usageChart"; + +vi.mock("./answerExport", async (importOriginal) => ({ + ...(await importOriginal()), + exportAriaAnswer: vi.fn(() => true), +})); + +const message: ChatMessage = { + id: "answer-1", + role: "assistant", + content: "## Spend overview\n\nThe **primary model** accounts for most of your spend.", + timestamp: 1, +}; +const chart: UsageChart = { + kind: "bar", + title: "Spend by model", + period: "2026-10-19 to 2026-10-23 (UTC)", + scope: "Your usage", + unit: "usd", + total: 4, + data: [ + { label: "Primary", value: 3.25 }, + { label: "Secondary", value: 0.75 }, + ], +}; + +beforeEach(() => { + vi.mocked(exportAriaAnswer).mockReset().mockReturnValue(true); + vi.mocked(toast.error).mockClear(); +}); + +describe("AriaAnswer", () => { + it("should render a text-only Markdown answer with one export action", () => { + render(); + + expect(screen.getByRole("article", { name: "Aria Agent answer" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Spend overview", level: 2 })).toBeInTheDocument(); + expect(screen.getByText("primary model")).toBeInTheDocument(); + expect(screen.queryByRole("figure")).not.toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: "Export answer" })).toHaveLength(1); + expect(screen.queryByRole("button", { name: /copy/i })).not.toBeInTheDocument(); + }); + + it("should present the chart's exact accessible values alongside the formatted explanation", () => { + render(); + + const figure = screen.getByRole("figure", { name: "Spend by model" }); + const values = within(figure).getByRole("table", { name: "Spend by model values" }); + expect(within(values).getByRole("row", { name: "Primary $3.25" })).toBeInTheDocument(); + expect(within(values).getByRole("row", { name: "Secondary $0.75" })).toBeInTheDocument(); + expect(within(figure).getByText("$4.00")).toBeInTheDocument(); + expect(within(figure).getByText("Your usage · 2026-10-19 to 2026-10-23 (UTC)")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Spend overview" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /copy/i })).not.toBeInTheDocument(); + }); + + it("should export the current question, explanation and chart without including adjacent receipts", () => { + const question = "Which model costs the most?"; + render( + <> + + + , + ); + act(() => fireEvent.click(screen.getByRole("button", { name: "Export answer" }))); + + expect(exportAriaAnswer).toHaveBeenCalledOnce(); + const [exportedQuestion, exportedAnswer] = vi.mocked(exportAriaAnswer).mock.calls[0]; + expect(exportedQuestion).toBe(question); + expect(within(exportedAnswer).getByRole("figure", { name: "Spend by model" })).toBeInTheDocument(); + expect(within(exportedAnswer).getByRole("heading", { name: "Spend overview" })).toBeInTheDocument(); + expect(within(exportedAnswer).queryByText("Private action receipt")).not.toBeInTheDocument(); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("should explain when the PDF print dialog cannot open", () => { + vi.mocked(exportAriaAnswer).mockReturnValue(false); + render(); + act(() => fireEvent.click(screen.getByRole("button", { name: "Export answer" }))); + + expect(exportAriaAnswer).toHaveBeenCalledWith("", expect.any(HTMLElement)); + expect(toast.error).toHaveBeenCalledWith("Could not open the PDF print dialog. Please try again."); + }); + + it("should preserve full chart labels and exact values in the exported answer", () => { + const question = "Which deployment costs the most?"; + const detailedChart: UsageChart = { + ...chart, + total: null, + data: [ + { label: "provider/long-deployment-primary", value: 0.000123 }, + { label: "provider/long-deployment-secondary", value: null }, + { label: "Unused deployment", value: 0 }, + ], + }; + render(); + act(() => fireEvent.click(screen.getByRole("button", { name: "Export answer" }))); + const [exportedQuestion, exportedAnswer] = vi.mocked(exportAriaAnswer).mock.calls[0]; + + const report = createAriaAnswerDocument(exportedQuestion, exportedAnswer); + render( +
      Hidden tooltip
      +
      Injected HTML
      + +
      `); + const question = ""; + const report = previewReport(createAriaAnswerDocument(question, source)); + + expect(report.getElementsByTagName("header")[0]).toHaveTextContent(question); + expect(report.body).toHaveTextContent("Safe answer linked text"); + expect(report.body).not.toHaveTextContent("Secret action receipt"); + expect(report.body).not.toHaveTextContent("Copy answer"); + expect(report.body).not.toHaveTextContent("Hidden tooltip"); + expect(report.body).not.toHaveTextContent("Decoration"); + expect(report.body).not.toHaveTextContent("Injected HTML"); + for (const tag of ["script", "iframe", "img", "image", "foreignObject", "button"]) { + expect(report.getElementsByTagName(tag)).toHaveLength(0); + } + expect(report.getElementsByTagName("a")[0]).not.toHaveAttribute("href"); + expect(report.getElementsByTagName("path")[0]).not.toHaveAttribute("onclick"); + expect(report.getElementsByTagName("rect")[0]).not.toHaveAttribute("fill"); + expect(report.head).not.toHaveTextContent("evil.example"); + expect(report.getElementsByTagName("meta")[0].content).toContain("default-src 'none'"); + }); + + it("should retain local SVG clipping and gradients while omitting hover overlays", () => { + const source = + answer(` + + + `); + const report = previewReport(createAriaAnswerDocument("Show spend", source)); + const path = report.getElementsByTagName("path")[0]; + expect(path).toHaveAttribute("clip-path", "url(#plot)"); + expect(path).toHaveAttribute("fill", "url(#series)"); + expect(report.getElementsByTagName("rect")).toHaveLength(1); + expect(report.getElementsByTagName("stop")[0]).toHaveAttribute("stop-color", "#123456"); + }); +}); + +function printFrame() { + const frame = document.createElement("iframe"); + const printWindow = window; + vi.spyOn(frame, "contentWindow", "get").mockReturnValue(printWindow); + const print = vi.spyOn(printWindow, "print").mockImplementation(() => undefined); + vi.spyOn(printWindow, "focus").mockImplementation(() => undefined); + const createElement = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation((tagName, options) => + tagName === "iframe" ? frame : createElement(tagName, options), + ); + return { frame, printWindow, print }; +} + +describe("exportAriaAnswer", () => { + it("should print only the supplied answer in an isolated document and clean up after printing", () => { + vi.useFakeTimers(); + const source = answer("

      The answer is $3.25.

      "); + const { frame, printWindow, print } = printFrame(); + expect(exportAriaAnswer("How much did I spend?", source)).toBe(true); + expect(print).toHaveBeenCalledOnce(); + expect(frame).toHaveAttribute("sandbox", "allow-modals allow-same-origin"); + expect(frame.contentDocument?.body).toHaveTextContent("How much did I spend?"); + expect(frame.contentDocument?.body).toHaveTextContent("The answer is $3.25."); + expect(frame.contentDocument?.body).not.toHaveTextContent("Export answer"); + printWindow.dispatchEvent(new Event("afterprint")); + expect(frame.isConnected).toBe(false); + expect(source.isConnected).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); + + it("should report unavailable printing and remove the temporary document", () => { + vi.useFakeTimers(); + const source = answer("

      Answer

      "); + const { frame, print } = printFrame(); + print.mockImplementation(() => { + throw new Error("Printing unavailable"); + }); + expect(exportAriaAnswer("Question", source)).toBe(false); + expect(frame.isConnected).toBe(false); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/ui/litellm-dashboard/src/components/liteadmin/answerExport.ts b/ui/litellm-dashboard/src/components/liteadmin/answerExport.ts new file mode 100644 index 00000000000..bb3529299df --- /dev/null +++ b/ui/litellm-dashboard/src/components/liteadmin/answerExport.ts @@ -0,0 +1,238 @@ +import { ARIA_AGENT_LABEL } from "./aria-brand"; + +const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; +const HTML_ELEMENTS = new Set([ + "article", + "section", + "div", + "span", + "p", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "strong", + "em", + "b", + "i", + "ul", + "ol", + "li", + "blockquote", + "pre", + "code", + "table", + "thead", + "tbody", + "tfoot", + "tr", + "th", + "td", + "br", + "hr", + "figure", + "figcaption", + "a", + "del", + "s", + "sup", + "sub", +]); +const SVG_ELEMENTS = new Set([ + "svg", + "g", + "path", + "rect", + "circle", + "ellipse", + "line", + "polyline", + "polygon", + "text", + "tspan", + "defs", + "clipPath", + "linearGradient", + "radialGradient", + "stop", + "title", + "desc", +]); +const SVG_ATTRIBUTES = new Set([ + "id", + "viewBox", + "width", + "height", + "x", + "y", + "x1", + "x2", + "y1", + "y2", + "cx", + "cy", + "r", + "rx", + "ry", + "d", + "points", + "transform", + "dx", + "dy", + "textLength", + "lengthAdjust", + "offset", + "gradientUnits", + "gradientTransform", + "spreadMethod", + "fx", + "fy", + "fr", + "clipPathUnits", + "preserveAspectRatio", +]); +const SVG_STYLES = [ + "fill", + "fill-opacity", + "fill-rule", + "stroke", + "stroke-width", + "stroke-opacity", + "stroke-dasharray", + "stroke-linecap", + "stroke-linejoin", + "opacity", + "font-size", + "font-family", + "font-weight", + "text-anchor", + "dominant-baseline", + "stop-color", + "stop-opacity", + "clip-path", +] as const; +const EXCLUDED = + "[data-export-exclude],button,input,textarea,select,script,style,link,meta,iframe,object,embed,img,video,audio,source,canvas,form,[role='tooltip'],.recharts-tooltip-wrapper,.recharts-tooltip-cursor"; + +const PRINT_CSS = ` + @page { margin: 18mm; } + * { box-sizing: border-box; } + body { margin: 0; color: #111827; background: #fff; font: 11pt/1.55 system-ui, sans-serif; } + header { margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid #d1d5db; } + h1 { margin: 0 0 12px; font-size: 22pt; } + h2, h3, h4, h5, h6 { margin: 20px 0 8px; break-after: avoid; line-height: 1.3; } + h2 { font-size: 17pt; } h3 { font-size: 14pt; } + p { margin: 8px 0; white-space: pre-wrap; overflow-wrap: anywhere; } + ul, ol { padding-left: 24px; } li { margin: 5px 0; } + table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 10pt; } + thead { display: table-header-group; } + th, td { padding: 8px; border-bottom: 1px solid #d1d5db; text-align: left; overflow-wrap: anywhere; } + th { background: #f3f4f6; } tr, svg { break-inside: avoid; } + figcaption { break-after: avoid; } + blockquote { margin-left: 0; padding-left: 16px; border-left: 3px solid #d1d5db; } + pre { white-space: pre-wrap; overflow-wrap: anywhere; padding: 12px; background: #f3f4f6; } + code { font-family: ui-monospace, monospace; font-size: 0.9em; } + svg { display: block; max-width: 100%; height: auto; overflow: visible; } + svg text { fill: #334155; } + [data-report-chart] { margin: 18px 0; } + body { print-color-adjust: exact; -webkit-print-color-adjust: exact; } +`; + +function safeSvgStyle(value: string): boolean { + return !/url\s*\(/i.test(value) || /^url\(["']?#[\w:.-]+["']?\)$/.test(value); +} + +function copySvgAttributes(source: Element, copy: Element): void { + for (const attribute of source.attributes) { + if (SVG_ATTRIBUTES.has(attribute.name)) copy.setAttribute(attribute.name, attribute.value); + } + const computed = source.ownerDocument.defaultView?.getComputedStyle(source); + for (const property of SVG_STYLES) { + const value = computed?.getPropertyValue(property) || source.getAttribute(property); + if (value && safeSvgStyle(value)) copy.setAttribute(property, value); + } +} + +function copyAnswerNode(source: Node, target: Document): Node | null { + if (source.nodeType === Node.TEXT_NODE) return target.createTextNode(source.textContent ?? ""); + if (source.nodeType !== Node.ELEMENT_NODE) return null; + const element = source as Element; + const svg = element.namespaceURI === SVG_NAMESPACE; + const hidden = element.hasAttribute("hidden") || (!svg && element.getAttribute("aria-hidden") === "true"); + if (element.matches(EXCLUDED) || hidden) return null; + if (svg && !SVG_ELEMENTS.has(element.localName)) return null; + + const copy = svg + ? target.createElementNS(SVG_NAMESPACE, element.localName) + : target.createElement(HTML_ELEMENTS.has(element.localName) ? element.localName : "div"); + if (svg) { + copySvgAttributes(element, copy); + } else { + for (const attribute of ["colspan", "rowspan", "start"]) { + const value = element.getAttribute(attribute); + if (value && /^\d+$/.test(value)) copy.setAttribute(attribute, value); + } + if (element.getAttribute("data-slot") === "chart") copy.setAttribute("data-report-chart", ""); + } + for (const child of element.childNodes) { + const safeChild = copyAnswerNode(child, target); + if (safeChild) copy.appendChild(safeChild); + } + return copy; +} + +export function createAriaAnswerDocument(question: string, answerElement: HTMLElement): Document { + const report = answerElement.ownerDocument.implementation.createHTMLDocument(`${ARIA_AGENT_LABEL} answer`); + report.documentElement.lang = "en"; + const policy = report.createElement("meta"); + policy.httpEquiv = "Content-Security-Policy"; + policy.content = "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"; + report.head.prepend(policy); + const style = report.createElement("style"); + style.textContent = PRINT_CSS; + report.head.appendChild(style); + const header = report.createElement("header"); + const title = report.createElement("h1"); + title.textContent = ARIA_AGENT_LABEL; + const prompt = report.createElement("p"); + prompt.textContent = question; + header.append(title, prompt); + report.body.appendChild(header); + const answer = copyAnswerNode(answerElement, report); + if (answer) report.body.appendChild(answer); + return report; +} + +export function exportAriaAnswer(question: string, answerElement: HTMLElement): boolean { + const owner = answerElement.ownerDocument; + const report = createAriaAnswerDocument(question, answerElement); + const frame = owner.createElement("iframe"); + frame.title = `${ARIA_AGENT_LABEL} print preview`; + frame.setAttribute("sandbox", "allow-modals allow-same-origin"); + frame.style.cssText = "position:fixed;inset:0;width:0;height:0;border:0;"; + owner.body.appendChild(frame); + const printWindow = frame.contentWindow; + const printDocument = frame.contentDocument; + if (!printWindow || !printDocument || !owner.defaultView) { + frame.remove(); + return false; + } + printDocument.replaceChild(printDocument.importNode(report.documentElement, true), printDocument.documentElement); + const cleanup = () => { + owner.defaultView?.clearTimeout(cleanupTimer); + printWindow.removeEventListener("afterprint", cleanup); + frame.remove(); + }; + printWindow.addEventListener("afterprint", cleanup, { once: true }); + const cleanupTimer = owner.defaultView.setTimeout(cleanup, 300_000); + try { + printWindow.focus(); + printWindow.print(); + return true; + } catch { + cleanup(); + return false; + } +} diff --git a/ui/litellm-dashboard/src/components/liteadmin/operations.ts b/ui/litellm-dashboard/src/components/liteadmin/operations.ts index 513624f9ffc..11b287da07c 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/operations.ts +++ b/ui/litellm-dashboard/src/components/liteadmin/operations.ts @@ -4,6 +4,13 @@ import { apiClient } from "@/components/networking"; import { ApiError } from "@/lib/http/client"; import type { components } from "@/lib/http/schema"; import { generatedKey, projectToolResult, type ResultKind } from "./toolResults"; +import { + buildUsageChart, + usageChartRequestSchema, + usageChartSource, + type UsageChart, + type UsageChartSource, +} from "./usageChart"; export interface LiteAdminAction { id: string; @@ -27,6 +34,7 @@ export interface OperationContext { beforeTool: () => void; confirm: (action: LiteAdminAction) => Promise; onResult: (action: LiteAdminAction, result: ActionResult) => void; + onChart: (chart: UsageChart) => void; } type Schemas = components["schemas"]; @@ -111,11 +119,31 @@ const requestLogsFields = { }; export function createLiteAdminOperations(context: OperationContext) { + const usageSources = new Map(); const active = () => { context.signal.throwIfAborted(); context.assertCurrent(); }; const auth = { accessToken: context.accessToken, signal: context.signal }; + const chartDefinition = { + name: "render_usage_chart", + description: + "Show one chart with the answer using the source_id of a successful usage lookup in this turn. Choose daily_spend for spend over time, model_spend or provider_spend for cost comparisons, daily_requests or daily_tokens for trends, or token_breakdown for input/output/cache analysis. Values come directly from that lookup. Follow with a short explanation, not a duplicate table. A later chart replaces the earlier chart for this answer. Read-only.", + parameters: usageChartRequestSchema, + function: (rawArgs: unknown) => { + active(); + context.beforeTool(); + const args = usageChartRequestSchema.parse(rawArgs); + const source = usageSources.get(args.source_id); + if (!source) return { success: false, message: "Fetch usage first and use the source_id from that result." }; + const chart = buildUsageChart(source, args.view); + if (!chart) + return { success: false, message: "No recorded data is available for this chart. Explain that in text." }; + context.onChart(chart); + return { success: true, chart }; + }, + }; + const renderChart = zodFunction(chartDefinition); const operation = (mode: "read" | "write" | "delete", kind: ResultKind, extraArguments?: Record) => >>( @@ -178,10 +206,18 @@ export function createLiteAdminOperations(context: OperationContext) { } const key = name === "key_create" || name === "user_create" ? generatedKey(outcome.value) : undefined; if (action) context.onResult(action, { status: "completed", ...(key ? { key } : {}) }); + const result = projectToolResult(kind, outcome.value, [context.accessToken, ...(key ? [key] : [])]); + let reportScope: "Gateway usage" | "All teams" | "All tags" = "Gateway usage"; + if (name === "get_team_usage_data") reportScope = "All teams"; + if (name === "get_tag_usage_data") reportScope = "All tags"; + const source = kind === "usage" ? usageChartSource(result, args, context.access, reportScope) : undefined; + const sourceId = source ? crypto.randomUUID() : undefined; + if (source && sourceId) usageSources.set(sourceId, source); return { operation: name, success: true, - result: projectToolResult(kind, outcome.value, [context.accessToken, ...(key ? [key] : [])]), + result, + ...(sourceId ? { source_id: sourceId } : {}), }; }, }; @@ -191,9 +227,10 @@ export function createLiteAdminOperations(context: OperationContext) { if (context.access.mode === "usage" && context.access.scope.type === "user") { const userId = context.access.scope.userId; return [ + renderChart, operation("read", "usage")( "get_usage_data", - "View your dated usage totals, daily trends, and top models, providers and keys", + "View your dated usage totals, daily trends, and top models, providers and keys with prompt, completion, cache-read and cache-creation token counts when available. Do not add cache counts to total_tokens", dated({}), (a) => { if (!userId.trim()) throw new Error("A signed-in user is required to query usage."); @@ -207,21 +244,22 @@ export function createLiteAdminOperations(context: OperationContext) { } const usageOperations = [ + renderChart, operation("read", "usage")( "get_usage_data", - "View dated global or user usage totals, daily trends, and top models, providers and keys", + "View dated global or user usage totals, daily trends, and top models, providers and keys with prompt, completion, cache-read and cache-creation token counts when available. Do not add cache counts to total_tokens", dated({ user_id: optionalText }), (a) => apiClient.get("/user/daily/activity/aggregated", { ...auth, query: { ...a, api_key_limit: 20 } }), ), operation("read", "usage")( "get_team_usage_data", - "View dated usage by team, including model and provider breakdowns. Omit team_ids for all teams", + "View dated usage by team, including model and provider breakdowns with prompt, completion, cache-read and cache-creation token counts when available. Do not add cache counts to total_tokens. Omit team_ids for all teams", dated({ team_ids: optionalText }), (a) => apiClient.get("/team/daily/activity/aggregated", { ...auth, query: { ...a, api_key_limit: 20 } }), ), operation("read", "usage")( "get_tag_usage_data", - "View dated usage by tag, including model and provider breakdowns. Omit tags for all tags", + "View dated usage by tag, including model and provider breakdowns with prompt, completion, cache-read and cache-creation token counts when available. Do not add cache counts to total_tokens. Omit tags for all tags", dated({ tags: optionalText }), (a) => apiClient.get("/tag/daily/activity/aggregated", { ...auth, query: { ...a, api_key_limit: 20 } }), ), diff --git a/ui/litellm-dashboard/src/components/liteadmin/toolResults.test.ts b/ui/litellm-dashboard/src/components/liteadmin/toolResults.test.ts index 31d3aa01810..b23b2c6d531 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/toolResults.test.ts +++ b/ui/litellm-dashboard/src/components/liteadmin/toolResults.test.ts @@ -143,38 +143,157 @@ describe("model result privacy", () => { }); }); - it("should aggregate usage across the full date range before ranking and sanitize names and metadata", () => { - const metrics = { spend: 10, api_requests: 3, total_tokens: 50 }; - const raw = { - metadata: { total_spend: 20, total_api_requests: 6, total_tokens: 100, secret: "private" }, - results: ["2031-05-01", "2031-05-02"].map((date) => ({ - date, - metrics, - breakdown: { - providers: { "provider captured-token": { metrics, messages: ["private prompt"] } }, - entities: { - "tag sk-secret": { metrics, metadata: { team_alias: "Team captured-token", secret: "private" } }, - }, - api_keys: { "sk-private-key": { metrics, metadata: { key_alias: "worker", raw_key: "private" } } }, + it.each(["team", "tag"])( + "should aggregate all token counts across dates and %s usage without exposing private fields", + (entity) => { + const days = [ + { + date: "2031-05-01", + spend: 10, + api_requests: 3, + total_tokens: 50, + prompt_tokens: 40, + completion_tokens: 10, + cache_read_input_tokens: 15, + cache_creation_input_tokens: 5, }, - })), + { + date: "2031-05-02", + spend: 20, + api_requests: 4, + total_tokens: 75, + prompt_tokens: 60, + completion_tokens: 15, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 10, + }, + ]; + const totals = { + total_spend: 30, + total_api_requests: 7, + total_tokens: 125, + total_prompt_tokens: 100, + total_completion_tokens: 25, + total_cache_read_input_tokens: 35, + total_cache_creation_input_tokens: 15, + }; + const raw = { + metadata: { ...totals, secret: "private" }, + results: days.map(({ date, ...metrics }) => ({ + date, + metrics, + breakdown: { + models: { "model captured-token": { metrics, messages: ["private prompt"] } }, + providers: { "provider captured-token": { metrics, messages: ["private prompt"] } }, + entities: { + [`${entity} sk-secret`]: { + metrics, + metadata: { team_alias: "Team captured-token", secret: "private" }, + }, + }, + api_keys: { "sk-private-key": { metrics, metadata: { key_alias: "worker", raw_key: "private" } } }, + }, + })), + }; + const combined = { + spend: 30, + api_requests: 7, + total_tokens: 125, + prompt_tokens: 100, + completion_tokens: 25, + cache_read_input_tokens: 35, + cache_creation_input_tokens: 15, + }; + const expected = { + totals, + daily: days, + breakdown_limit: 20, + top_models: [{ name: "model [redacted]", ...combined }], + top_providers: [{ name: "provider [redacted]", ...combined }], + top_entities: [{ name: `${entity} [redacted]`, alias: "Team [redacted]", ...combined }], + top_api_keys: [{ name: "[redacted]", alias: "worker", ...combined }], + }; + expect(projectToolResult("usage", raw, ["captured-token"])).toEqual(expected); + }, + ); + + it.each([undefined, null, 0])("should preserve zero or unavailable token counts reported as %s", (count) => { + const tokens = { + prompt_tokens: count, + completion_tokens: count, + cache_read_input_tokens: count, + cache_creation_input_tokens: count, + }; + const metrics = { spend: 0, api_requests: 0, total_tokens: 0, ...tokens }; + const metadata = { + total_prompt_tokens: count, + total_completion_tokens: count, + total_cache_read_input_tokens: count, + total_cache_creation_input_tokens: count, + }; + const daily = ["2031-05-01", "2031-05-02"].map((date) => ({ date, ...metrics })); + const raw = { + metadata, + results: daily.map(({ date }) => ({ date, metrics, breakdown: { models: { model: { metrics } } } })), }; const expected = { - totals: { total_spend: 20, total_api_requests: 6, total_tokens: 100 }, - daily: ["2031-05-01", "2031-05-02"].map((date) => ({ date, ...metrics })), + totals: metadata, + daily, breakdown_limit: 20, - top_models: [], - top_providers: [{ name: "provider [redacted]", spend: 20, api_requests: 6, total_tokens: 100 }], - top_entities: [ - { name: "tag [redacted]", alias: "Team [redacted]", spend: 20, api_requests: 6, total_tokens: 100 }, + top_models: [ + { + name: "model", + ...metrics, + prompt_tokens: count ?? undefined, + completion_tokens: count ?? undefined, + cache_read_input_tokens: count ?? undefined, + cache_creation_input_tokens: count ?? undefined, + }, ], - top_api_keys: [{ name: "[redacted]", alias: "worker", spend: 20, api_requests: 6, total_tokens: 100 }], + top_providers: [], + top_entities: [], + top_api_keys: [], }; - expect(projectToolResult("usage", raw, ["captured-token"])).toEqual(expected); + expect(projectToolResult("usage", raw, [])).toEqual(expected); }); + it.each([0, 1, 2])( + "should leave partially reported token counts unavailable when day %s omits them", + (missingDay) => { + const metrics = { spend: 1, api_requests: 1, total_tokens: 20 }; + const tokens = { + prompt_tokens: 15, + completion_tokens: 5, + cache_read_input_tokens: 6, + cache_creation_input_tokens: 2, + }; + const raw = { + results: Array.from({ length: 3 }, (_, index) => { + const dayMetrics = index === missingDay ? metrics : { ...metrics, ...tokens }; + return { + date: `2031-05-0${index + 1}`, + metrics: dayMetrics, + breakdown: { models: { model: { metrics: dayMetrics } }, entities: { entity: { metrics: dayMetrics } } }, + }; + }), + }; + const result = projectToolResult("usage", raw, []); + expect(result).toHaveProperty("top_models", [{ name: "model", spend: 3, api_requests: 3, total_tokens: 60 }]); + expect(result).toHaveProperty("top_entities", [{ name: "entity", spend: 3, api_requests: 3, total_tokens: 60 }]); + expect(result).toHaveProperty("totals", undefined); + }, + ); + it("should bound usage breakdowns after ranking while keeping complete totals", () => { - const metrics = { spend: 300, api_requests: 30, total_tokens: 900 }; + const metrics = { + spend: 300, + api_requests: 30, + total_tokens: 900, + prompt_tokens: 700, + completion_tokens: 200, + cache_read_input_tokens: 250, + cache_creation_input_tokens: 100, + }; const result = projectToolResult( "usage", { @@ -207,6 +326,26 @@ describe("model result privacy", () => { ); }); + it("should keep the usage output size limit when token breakdowns enlarge the response", () => { + const raw = { + results: Array.from({ length: 200 }, () => ({ + date: "2031-05-01", + metrics: { + spend: 1, + api_requests: 1, + total_tokens: 20, + prompt_tokens: 15, + completion_tokens: 5, + cache_read_input_tokens: 6, + cache_creation_input_tokens: 2, + }, + })), + }; + expect(projectToolResult("usage", raw, [])).toEqual({ + notice: "The result is too large to summarize safely. Use a smaller page or narrower filters.", + }); + }); + it("bounds list and string sizes and rejects results that exceed the overall output budget", () => { const raw = { users: Array.from({ length: 80 }, (_, index) => ({ user_id: String(index), user_alias: "x".repeat(800) })), diff --git a/ui/litellm-dashboard/src/components/liteadmin/toolResults.ts b/ui/litellm-dashboard/src/components/liteadmin/toolResults.ts index 64198a51847..7cfb809f668 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/toolResults.ts +++ b/ui/litellm-dashboard/src/components/liteadmin/toolResults.ts @@ -5,6 +5,10 @@ export type ResultKind = "key" | "team" | "user" | "budget" | "spend" | "usage" const limitedList = >(schema: Schema, limit = 50) => z.preprocess((value) => (Array.isArray(value) ? value.slice(0, limit) : value), z.array(schema)); +function sumKnownTokenCounts(left: number | null | undefined, right: number | null | undefined): number | undefined { + return left == null || right == null ? undefined : left + right; +} + export function generatedKey(value: unknown): string | undefined { const result = z.object({ key: z.string().min(1) }).safeParse(value); return result.success ? result.data.key : undefined; @@ -133,11 +137,16 @@ export function projectToolResult(kind: ResultKind, value: unknown, secrets: rea cache_hit: z.union([text, z.boolean()]), }; const log = z.object(logFields); - const usageMetrics = z.object({ + const usageMetricFields = { spend: z.number().finite(), api_requests: z.number().finite(), total_tokens: z.number().finite(), - }); + prompt_tokens: number, + completion_tokens: number, + cache_read_input_tokens: number, + cache_creation_input_tokens: number, + }; + const usageMetrics = z.object(usageMetricFields); const usageEntry = z.object({ metrics: usageMetrics, metadata: z.object({ key_alias: text, team_alias: text }).optional(), @@ -155,6 +164,8 @@ export function projectToolResult(kind: ResultKind, value: unknown, secrets: rea total_tokens: number, total_prompt_tokens: number, total_completion_tokens: number, + total_cache_read_input_tokens: number, + total_cache_creation_input_tokens: number, total_successful_requests: number, total_failed_requests: number, total_api_keys: number, @@ -181,11 +192,23 @@ export function projectToolResult(kind: ResultKind, value: unknown, secrets: rea const previous = totals.get(name)?.metrics; totals.set(name, { metadata: entry.metadata, - metrics: { - spend: (previous?.spend ?? 0) + entry.metrics.spend, - api_requests: (previous?.api_requests ?? 0) + entry.metrics.api_requests, - total_tokens: (previous?.total_tokens ?? 0) + entry.metrics.total_tokens, - }, + metrics: previous + ? { + spend: previous.spend + entry.metrics.spend, + api_requests: previous.api_requests + entry.metrics.api_requests, + total_tokens: previous.total_tokens + entry.metrics.total_tokens, + prompt_tokens: sumKnownTokenCounts(previous.prompt_tokens, entry.metrics.prompt_tokens), + completion_tokens: sumKnownTokenCounts(previous.completion_tokens, entry.metrics.completion_tokens), + cache_read_input_tokens: sumKnownTokenCounts( + previous.cache_read_input_tokens, + entry.metrics.cache_read_input_tokens, + ), + cache_creation_input_tokens: sumKnownTokenCounts( + previous.cache_creation_input_tokens, + entry.metrics.cache_creation_input_tokens, + ), + } + : entry.metrics, }); } } diff --git a/ui/litellm-dashboard/src/components/liteadmin/usageChart.test.ts b/ui/litellm-dashboard/src/components/liteadmin/usageChart.test.ts new file mode 100644 index 00000000000..0966ab3a88f --- /dev/null +++ b/ui/litellm-dashboard/src/components/liteadmin/usageChart.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; +import { + buildUsageChart, + formatUsageChartValue, + usageChartRequestSchema, + usageChartSource, + type UsageChartSource, +} from "./usageChart"; + +const query = { start_date: "2031-05-01", end_date: "2031-05-02" }; +const admin = { mode: "admin" } as const; +const report = { daily: [{ date: query.start_date, spend: 0 }], top_models: [], top_providers: [] }; +const source = (data: UsageChartSource["report"]): UsageChartSource => ({ + report: data, + period: `${query.start_date} to ${query.end_date} (UTC)`, + scope: "Gateway usage", +}); + +describe("usage chart sources", () => { + it("should retain validated usage data with its date range and account scope", () => { + expect(usageChartSource(report, query, admin)).toEqual(source(report)); + expect( + usageChartSource( + report, + { ...query, user_id: "viewer" }, + { mode: "usage", scope: { type: "user", userId: "viewer" } }, + ), + ).toMatchObject({ scope: "Your usage" }); + }); + + it.each(["All teams", "All tags"] as const)( + "should label an unfiltered %s report and retain combined filters", + (scope) => { + expect(usageChartSource(report, query, admin, scope)?.scope).toBe(scope); + const filters = { ...query, user_id: "member", team_ids: "team-a", tags: "production" }; + expect(usageChartSource(report, filters, admin, scope)?.scope).toBe( + "User member · Teams team-a · Tags production", + ); + }, + ); + + it.each([-1, Number.NaN, Number.POSITIVE_INFINITY, "12"])("should reject invalid metric %s", (spend) => { + expect(usageChartSource({ ...report, daily: [{ date: query.start_date, spend }] }, query, admin)).toBeUndefined(); + }); + + it("should reject malformed reports or date ranges before creating a chart source", () => { + expect(usageChartSource({ daily: report.daily }, query, admin)).toBeUndefined(); + expect(usageChartSource({ ...report, daily: [{ date: "yesterday", spend: 2 }] }, query, admin)).toBeUndefined(); + expect(usageChartSource(report, { ...query, start_date: "2031-02-30" }, admin)).toBeUndefined(); + }); + + it("should accept only a source reference and a supported view in chart requests", () => { + const request = { source_id: "recorded-lookup", view: "daily_spend" }; + expect(usageChartRequestSchema.safeParse(request).success).toBe(true); + expect(usageChartRequestSchema.safeParse({ ...request, data: [{ value: 999 }] }).success).toBe(false); + expect(usageChartRequestSchema.safeParse({ ...request, source_id: "" }).success).toBe(false); + expect(usageChartRequestSchema.safeParse({ ...request, view: "arbitrary_chart" }).success).toBe(false); + }); +}); + +describe("usage chart values", () => { + it.each([ + ["daily_spend", "spend", "usd"], + ["daily_requests", "api_requests", "requests"], + ["daily_tokens", "total_tokens", "tokens"], + ] as const)( + "should sort %s chronologically and preserve missing values separately from zero", + (view, metric, unit) => { + const data = { + ...report, + daily: [ + { date: "2031-05-03", [metric]: 7 }, + { date: "2031-05-01", [metric]: 0 }, + { date: "2031-05-02", [metric]: null }, + ], + }; + const chart = buildUsageChart(source(data), view); + const expectedChart = { + kind: "line", + unit, + total: null, + data: [ + { label: "2031-05-01", value: 0 }, + { label: "2031-05-02", value: null }, + { label: "2031-05-03", value: 7 }, + ], + }; + expect(chart).toMatchObject(expectedChart); + expect(data.daily[0].date).toBe("2031-05-03"); + }, + ); + + it("should preserve independent prompt, completion and cache totals without adding cache to total tokens", () => { + const chart = buildUsageChart( + source({ + ...report, + daily: [ + { + date: query.start_date, + total_tokens: 50, + prompt_tokens: 40, + completion_tokens: 10, + cache_read_input_tokens: 15, + cache_creation_input_tokens: 5, + }, + { + date: query.end_date, + total_tokens: 75, + prompt_tokens: 60, + completion_tokens: 15, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 10, + }, + ], + }), + "token_breakdown", + ); + const expectedChart = { + kind: "bar", + unit: "tokens", + total: 125, + data: [ + { label: "Prompt", value: 100 }, + { label: "Completion", value: 25 }, + { label: "Cache read", value: 35 }, + { label: "Cache creation", value: 15 }, + ], + }; + expect(chart).toMatchObject(expectedChart); + }); + + it("should leave an incomplete token counter unavailable while retaining known zeros", () => { + const chart = buildUsageChart( + source({ + ...report, + daily: [ + { date: query.start_date, total_tokens: 8, prompt_tokens: 8, cache_read_input_tokens: 0 }, + { date: query.end_date, total_tokens: 12, prompt_tokens: null, cache_read_input_tokens: 0 }, + ], + }), + "token_breakdown", + ); + expect(chart?.total).toBe(20); + expect(chart?.data).toEqual([ + { label: "Prompt", value: null }, + { label: "Completion", value: null }, + { label: "Cache read", value: 0 }, + { label: "Cache creation", value: null }, + ]); + }); + + it.each([ + ["model_spend", "top_models"], + ["provider_spend", "top_providers"], + ] as const)("should show the six leading entries and all remaining recorded spend for %s", (view, dimension) => { + const ranked = [5, 20, 1, 10, 15, 25, 2, 4].map((spend) => ({ name: `entry-${spend}`, spend })); + const chart = buildUsageChart( + source({ ...report, daily: [{ date: query.start_date, spend: 100 }], [dimension]: ranked }), + view, + ); + expect(chart?.data).toEqual([ + { label: "entry-25", value: 25 }, + { label: "entry-20", value: 20 }, + { label: "entry-15", value: 15 }, + { label: "entry-10", value: 10 }, + { label: "entry-5", value: 5 }, + { label: "entry-4", value: 4 }, + { label: "Other", value: 21 }, + ]); + expect(chart?.data.reduce((sum, row) => sum + (row.value ?? 0), 0)).toBe(chart?.total); + expect(ranked[0].spend).toBe(5); + }); + + it("should not invent remaining spend when the daily total is incomplete", () => { + const chart = buildUsageChart( + source({ + ...report, + daily: [{ date: query.start_date, spend: null }], + top_models: [{ name: "Known model", spend: 5 }], + }), + "model_spend", + ); + expect(chart?.total).toBeNull(); + expect(chart?.data).toEqual([{ label: "Known model", value: 5 }]); + }); + + it.each(["daily_spend", "model_spend", "provider_spend", "token_breakdown"] as const)( + "should omit %s when its source has no recorded rows", + (view) => expect(buildUsageChart(source({ daily: [], top_models: [], top_providers: [] }), view)).toBeUndefined(), + ); + + it("should display tiny dollar amounts without rounding them to zero and label unavailable values", () => { + expect(formatUsageChartValue(0.000123, "usd")).toBe("$0.000123"); + expect(formatUsageChartValue(0, "usd")).toBe("$0.00"); + expect(formatUsageChartValue(null, "tokens")).toBe("Unavailable"); + expect(formatUsageChartValue(12345, "requests")).toBe("12,345"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/liteadmin/usageChart.ts b/ui/litellm-dashboard/src/components/liteadmin/usageChart.ts new file mode 100644 index 00000000000..b6a6d1a008f --- /dev/null +++ b/ui/litellm-dashboard/src/components/liteadmin/usageChart.ts @@ -0,0 +1,148 @@ +import { z } from "zod"; +import type { AriaAccess } from "./operations"; + +export const usageChartRequestSchema = z.strictObject({ + source_id: z.string().min(1).max(100), + view: z.enum(["daily_spend", "model_spend", "provider_spend", "daily_requests", "daily_tokens", "token_breakdown"]), +}); + +type UsageChartView = z.infer["view"]; +const metric = z.number().finite().nonnegative().nullish(); +const metricFields = { + spend: metric, + api_requests: metric, + total_tokens: metric, + prompt_tokens: metric, + completion_tokens: metric, + cache_read_input_tokens: metric, + cache_creation_input_tokens: metric, +}; +const metrics = z.object(metricFields); +const usageReportSchema = z.object({ + daily: z.array(metrics.extend({ date: z.iso.date() })).max(367), + top_models: z.array(metrics.extend({ name: z.string().nullish() })).max(20), + top_providers: z.array(metrics.extend({ name: z.string().nullish() })).max(20), +}); +const queryFields = { + start_date: z.iso.date(), + end_date: z.iso.date(), + user_id: z.string().nullish(), + team_ids: z.string().nullish(), + tags: z.string().nullish(), +}; +const usageQuerySchema = z.object(queryFields); + +export interface UsageChartSource { + report: z.infer; + period: string; + scope: string; +} + +export interface UsageChart { + kind: "bar" | "line"; + title: string; + period: string; + scope: string; + unit: "usd" | "requests" | "tokens"; + total: number | null; + data: { label: string; value: number | null }[]; + note?: string; +} + +export function usageChartSource( + value: unknown, + query: unknown, + access: AriaAccess, + defaultScope: "Gateway usage" | "All teams" | "All tags" = "Gateway usage", +): UsageChartSource | undefined { + const report = usageReportSchema.safeParse(value); + const args = usageQuerySchema.safeParse(query); + if (!report.success || !args.success) return undefined; + const { start_date, end_date, user_id, team_ids, tags } = args.data; + const filters = [user_id && `User ${user_id}`, team_ids && `Teams ${team_ids}`, tags && `Tags ${tags}`].filter( + Boolean, + ); + const scope = + access.mode === "usage" && access.scope.type === "user" ? "Your usage" : filters.join(" · ") || defaultScope; + return { report: report.data, period: `${start_date} to ${end_date} (UTC)`, scope }; +} + +function knownTotal(values: readonly (number | null | undefined)[]): number | null { + if (values.length === 0 || values.some((value) => value == null)) return null; + return values.reduce((total, value) => total + (value ?? 0), 0); +} + +function rankedData(source: UsageChartSource, dimension: "top_models" | "top_providers"): UsageChart["data"] { + const ranked = [...source.report[dimension]].sort((a, b) => (b.spend ?? 0) - (a.spend ?? 0)); + const shown = ranked.slice(0, 6).map((row) => ({ label: row.name || "Unknown", value: row.spend ?? null })); + const total = knownTotal(source.report.daily.map((day) => day.spend)); + const visibleTotal = knownTotal(shown.map((row) => row.value)); + const remainder = total == null || visibleTotal == null ? null : total - visibleTotal; + return remainder != null && remainder > 1e-10 ? [...shown, { label: "Other", value: remainder }] : shown; +} + +const dailyViews = { + daily_spend: { title: "Daily spend", metric: "spend", unit: "usd" }, + daily_requests: { title: "Daily requests", metric: "api_requests", unit: "requests" }, + daily_tokens: { title: "Daily tokens", metric: "total_tokens", unit: "tokens" }, +} as const; + +export function buildUsageChart(source: UsageChartSource, view: UsageChartView): UsageChart | undefined { + const base = { period: source.period, scope: source.scope }; + if (view === "model_spend" || view === "provider_spend") { + const data = rankedData(source, view === "model_spend" ? "top_models" : "top_providers"); + if (data.length === 0) return undefined; + return { + ...base, + kind: "bar", + title: view === "model_spend" ? "Spend by model" : "Spend by provider", + unit: "usd", + total: knownTotal(source.report.daily.map((day) => day.spend)), + data, + note: data.some((row) => row.label === "Other") + ? "Up to six leading entries. Other includes remaining recorded spend." + : undefined, + }; + } + if (view === "token_breakdown") { + if (source.report.daily.length === 0) return undefined; + const tokenFields = [ + ["Prompt", "prompt_tokens"], + ["Completion", "completion_tokens"], + ["Cache read", "cache_read_input_tokens"], + ["Cache creation", "cache_creation_input_tokens"], + ] as const; + return { + ...base, + kind: "bar", + title: "Token breakdown", + unit: "tokens", + total: knownTotal(source.report.daily.map((day) => day.total_tokens)), + data: tokenFields.map(([label, field]) => ({ + label, + value: knownTotal(source.report.daily.map((day) => day[field])), + })), + note: "Cache counts are reported separately. Do not add them to total tokens.", + }; + } + const config = dailyViews[view]; + const data = [...source.report.daily] + .sort((a, b) => a.date.localeCompare(b.date)) + .map((day) => ({ label: day.date, value: day[config.metric] ?? null })); + if (data.length === 0) return undefined; + return { + ...base, + kind: "line", + title: config.title, + unit: config.unit, + total: knownTotal(data.map((point) => point.value)), + data, + }; +} + +export function formatUsageChartValue(value: number | null, unit: UsageChart["unit"]): string { + if (value == null) return "Unavailable"; + return unit === "usd" + ? value.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 6 }) + : value.toLocaleString("en-US"); +} diff --git a/ui/litellm-dashboard/src/components/liteadmin/useLiteAdmin.ts b/ui/litellm-dashboard/src/components/liteadmin/useLiteAdmin.ts index 5b4196b36fc..6c6b385a164 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/useLiteAdmin.ts +++ b/ui/litellm-dashboard/src/components/liteadmin/useLiteAdmin.ts @@ -8,11 +8,12 @@ import { getCookie } from "@/utils/cookieUtils"; import { checkTokenValidity } from "@/utils/jwtUtils"; import { runLiteAdmin } from "./agent"; import type { ActionResult, AriaAccess, LiteAdminAction } from "./operations"; +import type { UsageChart } from "./usageChart"; type ActionState = { status: "review" | "applying" | "cancelled" } | ActionResult; export type ActionEntry = { kind: "action"; id: string; action: LiteAdminAction } & ActionState; export type ConversationEntry = - | { kind: "message"; id: string; message: ChatMessage } + | { kind: "message"; id: string; message: ChatMessage; chart?: UsageChart; question?: string } | ActionEntry | { kind: "error"; id: string; text: string }; export interface LiteAdminSession { @@ -28,6 +29,7 @@ type ActiveTurn = { controller: AbortController; outcome: "none" | "submitted" | "completed" | "unknown"; approval?: { id: string; resolve: (approved: boolean) => void }; + chart?: UsageChart; }; const INTERRUPTED_WRITE = "A submitted change may have completed. Check the relevant page before trying again."; const RESOURCE_QUERIES = new Set([ @@ -131,7 +133,16 @@ export function useLiteAdmin(session: LiteAdminSession) { onMessage: (content) => { assertCurrent(); const reply: ChatMessage = { id: crypto.randomUUID(), role: "assistant", content, timestamp: Date.now() }; - setEntries((current) => [...current, { kind: "message", id: reply.id, message: reply }]); + const chart = turn.chart; + turn.chart = undefined; + setEntries((current) => [ + ...current, + { kind: "message", id: reply.id, message: reply, chart, question: message.content }, + ]); + }, + onChart: (chart) => { + assertCurrent(); + turn.chart = chart; }, confirm: (action) => new Promise((resolve) => { diff --git a/ui/litellm-dashboard/src/components/shared/charts/bar_chart.test.tsx b/ui/litellm-dashboard/src/components/shared/charts/bar_chart.test.tsx index 3cd7bd2fd08..4de55550daa 100644 --- a/ui/litellm-dashboard/src/components/shared/charts/bar_chart.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/charts/bar_chart.test.tsx @@ -81,6 +81,28 @@ describe("BarChart", () => { expect(screen.getAllByText("beta").length).toBeGreaterThan(0); }); + it.each(["horizontal", "vertical"] as const)( + "should shorten category labels independently of numeric ticks in %s layout", + (layout) => { + const fullLabel = "primary-model-with-a-long-deployment-name"; + render( + `${label.slice(0, 13)}…`} + valueFormatter={(value) => `${value} USD`} + showLegend={false} + />, + ); + + expect(screen.getByText("primary-model…")).toBeInTheDocument(); + expect(screen.queryByText(fullLabel)).not.toBeInTheDocument(); + expect(screen.getAllByText(/ USD$/).length).toBeGreaterThan(0); + }, + ); + it("applies valueFormatter to the value axis ticks", () => { render( > = { data: readonly TDatum[]; index: string; + indexFormatter?: (value: string) => string; categories: readonly string[]; colors?: readonly ChartColor[]; colorByDatum?: boolean; @@ -32,6 +33,7 @@ export type BarChartProps> = { export function BarChart>({ data, index, + indexFormatter, categories, colors, colorByDatum = false, @@ -87,10 +89,19 @@ export function BarChart>({ axisLine={false} minTickGap={tickGap} interval="equidistantPreserveStart" + tickFormatter={indexFormatter} /> )} {vertical ? ( - + ) : ( )}