diff --git a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py index 7e272a2c59c..97ab91a46ac 100644 --- a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py +++ b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py @@ -1178,7 +1178,9 @@ async def _resolve_project_daily_activity_scope( found_by_id: Final = {p.project_id: p for p in projects} missing: Final = [pid for pid in requested if pid not in found_by_id] if missing: - raise _project_daily_activity_error(status_code=404, message=f"Project(s) not found: {missing}") + raise _project_daily_activity_error( + status_code=404, message=f"Project(s) not found: {', '.join(sorted(missing))}" + ) if not user_api_key_has_admin_view(user_api_key_dict): for project_id in requested: diff --git a/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py b/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py index 61a66d46e5c..1f7221fd2dd 100644 --- a/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py +++ b/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py @@ -1517,11 +1517,12 @@ async def test_get_project_daily_activity_unknown_project_404(monkeypatch): with pytest.raises(HTTPException) as exc_info: await get_project_daily_activity( user_api_key_dict=admin, - project_ids="does-not-exist", + project_ids="does-not-exist,also-missing", start_date="2026-09-01", end_date="2026-09-02", ) assert exc_info.value.status_code == 404 + assert exc_info.value.detail == {"error": "Project(s) not found: also-missing, does-not-exist"} mock_prisma.db.query_raw.assert_not_called() diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts index 749fc98c0d8..eaa1c89eab4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts @@ -17,7 +17,7 @@ export const useUISettings = (options?: { staleTime?: number; refetchInterval?: return useQuery>({ queryKey: uiSettingsKeys.list({}), queryFn: async () => await getUiSettings(), - staleTime: options?.staleTime ?? 60 * 60 * 1000, // 1 hour - data rarely changes + staleTime: options?.staleTime ?? 60 * 60 * 1000, gcTime: 60 * 60 * 1000, // 1 hour - keep in cache for 1 hour refetchInterval: options?.refetchInterval, }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx index 273e478528e..f55dc05f5c2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx @@ -10,16 +10,15 @@ import { type ProviderSpendRow, } from "./entityUsageAggregations"; import { buildCostBreakdownTiles, buildSummaryTiles, hasFlatCost, type SummaryTile } from "./entityUsageSummary"; +import { SummaryTileCard } from "./SummaryTileCard"; import { MoneyCell } from "@/components/shared/table_cells"; import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { hasCapability, type Capability } from "@/utils/capabilities"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; -import { ChevronDown, ChevronRight, Info } from "lucide-react"; import type { ColumnDef } from "@tanstack/react-table"; import PaginationStatusAlerts from "@/components/shared/PaginationStatusAlerts"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import React, { type ReactNode, useMemo, useState } from "react"; import TeamMultiSelect from "@/components/common_components/team_multi_select"; import UserDropdown from "@/components/common_components/UserDropdown"; @@ -358,29 +357,13 @@ const EntityUsage: React.FC = ({ [], ); - const chev = "size-3 text-muted-foreground"; - const expandIcon = showCostBreakdown ? : ; - - const renderSummaryTile = ({ title, value, className, tooltip, expandable }: SummaryTile) => ( - setShowCostBreakdown(!showCostBreakdown) : undefined} - > - -
-

{title}

- {tooltip ? ( - - } /> - {tooltip} - - ) : null} - {expandable ? expandIcon : null} -
-

{value}

-
-
+ const renderSummaryTile = (tile: SummaryTile) => ( + setShowCostBreakdown(!showCostBreakdown)} + /> ); const breakdownTiles = showFlatCost && showCostBreakdown ? buildCostBreakdownTiles(spendData.metadata) : []; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx index f5d6db67478..5ace97421e0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByProvider.tsx @@ -1,15 +1,12 @@ -import { DonutChart } from "@/components/shared/charts"; -import { DataTable } from "@/components/shared/DataTable"; import { MoneyCell } from "@/components/shared/table_cells"; -import { formatNumberWithCommas } from "@/utils/dataUtils"; import { Info } from "lucide-react"; import type { ColumnDef } from "@tanstack/react-table"; -import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Switch } from "@/components/ui/switch"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import React, { useState } from "react"; import { ProviderLogo } from "@/components/molecules/models/ProviderLogo"; -import { ChartLoader } from "@/components/shared/chart_loader"; + +import { SpendByCategoryPanel } from "../SpendByCategoryPanel"; type ProviderSpendData = { provider: string; @@ -70,13 +67,10 @@ const SpendByProvider: React.FC = ({ loading, isDateChangi const filteredProviderSpend = providerSpend.filter((provider) => { const isUnknown = provider.provider?.toLowerCase() === "unknown"; - // If includeUnknown is true, always include unknown provider if (isUnknown) { return includeUnknown; } - // If includeZeroSpend is true, include all providers (including those with 0 spend) - // Otherwise, only include providers with spend > 0 if (includeZeroSpend) { return true; } @@ -84,54 +78,37 @@ const SpendByProvider: React.FC = ({ loading, isDateChangi return provider.spend > 0; }); + const headerAction = ( + <> +
+ + +
+
+
+ + + } /> + Requests that failed to route to a provider + +
+ +
+ + ); + return ( - - - Spend by Provider - -
- - -
-
-
- - - } /> - Requests that failed to route to a provider - -
- -
-
-
- - {loading ? ( - - ) : ( -
- `$${formatNumberWithCommas(value, 2)}`} - colors={["cyan"]} - showLabel - startAngle={90} - endAngle={-270} - /> - row.provider} - noDataMessage="No provider usage data" - size="compact" - /> -
- )} -
-
+ row.provider} + noDataMessage="No provider usage data" + /> ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SummaryTileCard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SummaryTileCard.tsx new file mode 100644 index 00000000000..fcf616bca99 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SummaryTileCard.tsx @@ -0,0 +1,41 @@ +import { ChevronDown, ChevronRight, Info } from "lucide-react"; + +import { Card as ShadcnCard, CardContent } from "@/components/ui/card"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +import type { SummaryTile } from "./entityUsageSummary"; + +interface SummaryTileCardProps { + tile: SummaryTile; + expanded?: boolean; + onToggleExpand?: () => void; +} + +export function SummaryTileCard({ tile, expanded = false, onToggleExpand }: SummaryTileCardProps) { + const { title, value, className, tooltip, expandable } = tile; + const chev = "size-3 text-muted-foreground"; + const expandIcon = expanded ? : ; + + return ( + + +
+

{title}

+ {tooltip ? ( + + } /> + {tooltip} + + ) : null} + {expandable ? expandIcon : null} +
+

{value}

+
+
+ ); +} + +export default SummaryTileCard; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.test.tsx new file mode 100644 index 00000000000..4e67c870de0 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import ProjectSpendBreakdown from "./ProjectSpendBreakdown"; +import type { ProjectSpendRow } from "./projectUsageAggregations"; + +vi.mock("@/components/shared/chart_loader", () => ({ + ChartLoader: ({ isDateChanging }: { isDateChanging: boolean }) => ( +
{isDateChanging ? "Processing date selection..." : "Loading chart data..."}
+ ), +})); + +const mockProjectSpend: ProjectSpendRow[] = [ + { + project_id: "project-alpha", + project_alias: "Project Alpha", + spend: 150.5, + requests: 100, + successful_requests: 95, + failed_requests: 5, + tokens: 50000, + }, + { + project_id: "project-beta", + project_alias: "Project Beta", + spend: 200.75, + requests: 120, + successful_requests: 115, + failed_requests: 5, + tokens: 75000, + }, +]; + +describe("ProjectSpendBreakdown", () => { + it("displays the title", () => { + render(); + expect(screen.getByText("Spend by Project")).toBeInTheDocument(); + }); + + it("shows the loader instead of chart content while loading", () => { + render(); + expect(screen.getByTestId("chart-loader")).toBeInTheDocument(); + expect(screen.queryByText("No project usage data")).not.toBeInTheDocument(); + }); + + it("displays table headers", () => { + render(); + expect(screen.getByText("Project")).toBeInTheDocument(); + expect(screen.getByText("Spend")).toBeInTheDocument(); + expect(screen.getByText("Successful")).toBeInTheDocument(); + expect(screen.getByText("Failed")).toBeInTheDocument(); + expect(screen.getByText("Tokens")).toBeInTheDocument(); + }); + + it("displays each project's alias and formatted spend in the table", () => { + render(); + expect(screen.getAllByText("Project Alpha").length).toBeGreaterThan(0); + expect(screen.getAllByText("Project Beta").length).toBeGreaterThan(0); + expect(screen.getByText("$150.50")).toBeInTheDocument(); + expect(screen.getByText("$200.75")).toBeInTheDocument(); + }); + + it("shows the empty message when no project has usage", () => { + render(); + expect(screen.getByText("No project usage data")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.tsx new file mode 100644 index 00000000000..d2459e65665 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectSpendBreakdown.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import type { ColumnDef } from "@tanstack/react-table"; + +import { MoneyCell } from "@/components/shared/table_cells"; + +import { SpendByCategoryPanel } from "../SpendByCategoryPanel"; +import type { ProjectSpendRow } from "./projectUsageAggregations"; + +interface ProjectSpendBreakdownProps { + loading: boolean; + isDateChanging: boolean; + projectSpend: ProjectSpendRow[]; +} + +const columns: ColumnDef[] = [ + { + header: "Project", + accessorKey: "project_alias", + }, + { + header: "Spend", + accessorKey: "spend", + meta: { numeric: true }, + cell: ({ row }) => , + }, + { + header: "Successful", + accessorKey: "successful_requests", + meta: { numeric: true, className: "text-success" }, + cell: ({ row }) => row.original.successful_requests.toLocaleString(), + }, + { + header: "Failed", + accessorKey: "failed_requests", + meta: { numeric: true, className: "text-destructive" }, + cell: ({ row }) => row.original.failed_requests.toLocaleString(), + }, + { + header: "Tokens", + accessorKey: "tokens", + meta: { numeric: true }, + cell: ({ row }) => row.original.tokens.toLocaleString(), + }, +]; + +const ProjectSpendBreakdown: React.FC = ({ loading, isDateChanging, projectSpend }) => ( + row.project_id} + noDataMessage="No project usage data" + /> +); + +export default ProjectSpendBreakdown; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.test.tsx new file mode 100644 index 00000000000..7ea78355188 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.test.tsx @@ -0,0 +1,165 @@ +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { renderWithProviders, screen, testQueryClient, waitFor } from "@/../tests/test-utils"; +import * as networking from "@/components/networking"; + +import ProjectUsage from "./ProjectUsage"; +import type { EntityList } from "../EntityUsage/EntityUsage"; + +vi.mock("@/components/networking", () => ({ + projectDailyActivityCall: vi.fn(), +})); + +const PROJECT_LIST: EntityList[] = [ + { label: "Project Alpha", value: "project-alpha" }, + { label: "Project Beta", value: "project-beta" }, +]; + +const DATE_VALUE = { from: new Date("2026-09-01"), to: new Date("2026-09-08") }; + +describe("ProjectUsage", () => { + const mockProjectDailyActivityCall = vi.mocked(networking.projectDailyActivityCall); + + beforeEach(() => { + vi.clearAllMocks(); + testQueryClient.clear(); + }); + + it("shows an enterprise upsell and never calls the API when the caller is not a premium user", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Project Usage is an Enterprise feature")).toBeInTheDocument(); + expect(mockProjectDailyActivityCall).not.toHaveBeenCalled(); + }); + + it("prompts for a project before fetching anything", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Select at least one project above to view its usage.")).toBeInTheDocument(); + expect(mockProjectDailyActivityCall).not.toHaveBeenCalled(); + }); + + it("fetches and renders the picked project's daily spend", async () => { + mockProjectDailyActivityCall.mockResolvedValue({ + start_date: "2026-09-01", + end_date: "2026-09-08", + results: [ + { + date: "2026-09-01", + project_id: "project-alpha", + project_alias: "Project Alpha", + spend: 12.5, + prompt_tokens: 100, + completion_tokens: 50, + total_tokens: 150, + api_requests: 4, + successful_requests: 3, + failed_requests: 1, + }, + ], + }); + + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("option", { name: "Project Alpha" })); + + await waitFor(() => expect(mockProjectDailyActivityCall).toHaveBeenCalledTimes(1)); + expect(mockProjectDailyActivityCall).toHaveBeenCalledWith("test-token", DATE_VALUE.from, DATE_VALUE.to, [ + "project-alpha", + ]); + + await waitFor(() => expect(screen.getAllByText("$12.50").length).toBeGreaterThan(0)); + expect(screen.getAllByText("4").length).toBeGreaterThan(0); + expect(screen.getAllByText("Project Alpha").length).toBeGreaterThan(0); + }); + + it("shows an error instead of silently rendering zeros when the request fails", async () => { + mockProjectDailyActivityCall.mockRejectedValue(new Error("Project management is an enterprise feature")); + + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("option", { name: "Project Alpha" })); + + expect(await screen.findByText("Could not load project usage")).toBeInTheDocument(); + expect(screen.getByText("Project management is an enterprise feature")).toBeInTheDocument(); + expect(screen.queryByText("No project usage data")).not.toBeInTheDocument(); + }); + + it("renders a backend not-found message verbatim, without mangling the comma-joined list", async () => { + mockProjectDailyActivityCall.mockRejectedValue(new Error("Project(s) not found: also-missing, does-not-exist")); + + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("option", { name: "Project Alpha" })); + + expect(await screen.findByText("Project(s) not found: also-missing, does-not-exist")).toBeInTheDocument(); + }); + + it("shows a loading indicator while fetching data for a newly-added project", async () => { + let resolveSecondCall: (value: unknown) => void = () => {}; + mockProjectDailyActivityCall + .mockResolvedValueOnce({ + start_date: "2026-09-01", + end_date: "2026-09-08", + results: [ + { + date: "2026-09-01", + project_id: "project-alpha", + project_alias: "Project Alpha", + spend: 12.5, + prompt_tokens: 100, + completion_tokens: 50, + total_tokens: 150, + api_requests: 4, + successful_requests: 3, + failed_requests: 1, + }, + ], + }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecondCall = resolve; + }), + ); + + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("option", { name: "Project Alpha" })); + await waitFor(() => expect(screen.getAllByText("$12.50").length).toBeGreaterThan(0)); + + await user.click(screen.getByRole("combobox")); + await user.click(screen.getByRole("option", { name: "Project Beta" })); + + await waitFor(() => expect(screen.getAllByText("Loading chart data...").length).toBeGreaterThan(0)); + + resolveSecondCall({ + start_date: "2026-09-01", + end_date: "2026-09-08", + results: [], + }); + + await waitFor(() => expect(screen.queryAllByText("Loading chart data...").length).toBe(0)); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.tsx new file mode 100644 index 00000000000..5e48d05ecf6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/ProjectUsage.tsx @@ -0,0 +1,178 @@ +import React, { useMemo, useState } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { Info } from "lucide-react"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; +import { BarChart } from "@/components/shared/charts"; +import { ChartLoader } from "@/components/shared/chart_loader"; +import { MultiSelect, type MultiSelectOption } from "@/components/shared/MultiSelect"; +import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; +import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { projectDailyActivityCall } from "@/components/networking"; +import { extractProxyErrorMessage } from "@/lib/http/client"; +import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters"; + +import { buildSummaryTiles } from "../EntityUsage/entityUsageSummary"; +import { SummaryTileCard } from "../EntityUsage/SummaryTileCard"; +import type { EntityList } from "../EntityUsage/EntityUsage"; +import ProjectSpendBreakdown from "./ProjectSpendBreakdown"; +import { buildDailySpendSeries, buildProjectSpendBreakdown, summarizeProjectUsage } from "./projectUsageAggregations"; + +interface ProjectUsageProps { + accessToken: string | null; + projectList: EntityList[] | null; + dateValue: DateRangePickerValue; + premiumUser: boolean; +} + +const ProjectUsage: React.FC = ({ accessToken, projectList, dateValue, premiumUser }) => { + const [selectedProjectIds, setSelectedProjectIds] = useState([]); + + const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]); + const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]); + + const projectOptions = useMemo( + () => (projectList ?? []).map((project) => ({ label: project.label || project.value, value: project.value })), + [projectList], + ); + + const hasSelection = selectedProjectIds.length > 0; + const hasDateRange = !!startTime && !!endTime; + const hasRequestWindow = premiumUser && !!accessToken && hasDateRange; + const enabled = hasRequestWindow && hasSelection; + + const queryOptions = { + queryKey: ["project-daily-activity", selectedProjectIds, startTime?.toISOString(), endTime?.toISOString()], + queryFn: () => + projectDailyActivityCall(accessToken as string, startTime as Date, endTime as Date, selectedProjectIds), + enabled, + placeholderData: keepPreviousData, + }; + const { data, isPending, isFetching, isPlaceholderData, isError, error } = useQuery(queryOptions); + + const rows = useMemo(() => data?.results ?? [], [data]); + const summary = useMemo(() => summarizeProjectUsage(rows), [rows]); + const dailySpend = useMemo(() => buildDailySpendSeries(rows), [rows]); + const projectBreakdown = useMemo(() => buildProjectSpendBreakdown(rows), [rows]); + + if (!premiumUser) { + return ( + + Project Usage is an Enterprise feature + + Filtering usage by project requires a LiteLLM Enterprise license. Get a 7 day trial at{" "} + + litellm.ai/enterprise + + . + + + ); + } + + const isLoadingRows = isPending || (isFetching && isPlaceholderData); + + const renderResultsPanel = () => { + if (!hasSelection) { + return ( +
+ + +

+ Select at least one project above to view its usage. +

+
+
+
+ ); + } + + if (isError) { + return ( +
+ + Could not load project usage + {extractProxyErrorMessage(error)} + +
+ ); + } + + return ( + <> +
+ + +

Project Spend Overview

+ {isLoadingRows ? ( + + ) : ( +
+ {buildSummaryTiles(summary, false).map((tile) => ( + + ))} +
+ )} +
+
+
+ +
+ + + Daily Spend + + + {isLoadingRows ? ( + + ) : ( + + )} + + +
+ +
+ +
+ + ); + }; + + return ( +
+
+ + +
+

Projects

+ + } /> + Select one or more projects to compare their usage + +
+ +
+
+
+ + {renderResultsPanel()} +
+ ); +}; + +export default ProjectUsage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts new file mode 100644 index 00000000000..7ef733f5eac --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; + +import type { ProjectDailySpendRow } from "@/components/networking"; + +import { buildDailySpendSeries, buildProjectSpendBreakdown, summarizeProjectUsage } from "./projectUsageAggregations"; + +const row = (overrides: Partial = {}): ProjectDailySpendRow => ({ + date: "2026-09-01", + project_id: "project-alpha", + project_alias: "Project Alpha", + spend: 1, + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15, + api_requests: 3, + successful_requests: 2, + failed_requests: 1, + ...overrides, +}); + +describe("summarizeProjectUsage", () => { + it("returns all-zero totals for no rows", () => { + expect(summarizeProjectUsage([])).toEqual({ + total_spend: 0, + total_api_requests: 0, + total_successful_requests: 0, + total_failed_requests: 0, + total_tokens: 0, + }); + }); + + it("sums spend, requests, and tokens across every row regardless of project or date", () => { + const projectBetaRow: Partial = { + project_id: "project-beta", + date: "2026-09-02", + spend: 2.25, + api_requests: 4, + successful_requests: 4, + failed_requests: 0, + total_tokens: 40, + }; + const rows = [row({ spend: 1.5 }), row(projectBetaRow)]; + + expect(summarizeProjectUsage(rows)).toEqual({ + total_spend: 3.75, + total_api_requests: 7, + total_successful_requests: 6, + total_failed_requests: 1, + total_tokens: 55, + }); + }); +}); + +describe("buildDailySpendSeries", () => { + it("sums same-day spend across projects into a single point", () => { + const rows = [row(), row({ project_id: "project-beta", spend: 2 })]; + + expect(buildDailySpendSeries(rows)).toEqual([{ date: "2026-09-01", spend: 3 }]); + }); + + it("sorts distinct dates oldest first regardless of input order", () => { + const rows = [row({ date: "2026-09-03", spend: 3 }), row(), row({ date: "2026-09-02", spend: 2 })]; + + expect(buildDailySpendSeries(rows).map((point) => point.date)).toEqual(["2026-09-01", "2026-09-02", "2026-09-03"]); + }); +}); + +describe("buildProjectSpendBreakdown", () => { + it("sums a project's spend and tokens across every day into a single row", () => { + const secondDayRow: Partial = { + date: "2026-09-02", + spend: 2, + total_tokens: 20, + successful_requests: 1, + failed_requests: 2, + }; + const rows = [row({ total_tokens: 10, api_requests: 2, failed_requests: 0 }), row(secondDayRow)]; + + expect(buildProjectSpendBreakdown(rows)).toEqual([ + { + project_id: "project-alpha", + project_alias: "Project Alpha", + spend: 3, + requests: 5, + successful_requests: 3, + failed_requests: 2, + tokens: 30, + }, + ]); + }); + + it("sorts projects by spend descending", () => { + const rows = [ + row({ project_id: "project-cheap", project_alias: "Cheap" }), + row({ project_id: "project-costly", project_alias: "Costly", spend: 9 }), + ]; + + expect(buildProjectSpendBreakdown(rows).map((r) => r.project_id)).toEqual(["project-costly", "project-cheap"]); + }); + + it("falls back to the project id when the project has no alias", () => { + const rows = [row({ project_id: "project-untitled", project_alias: null })]; + + expect(buildProjectSpendBreakdown(rows)[0].project_alias).toBe("project-untitled"); + }); + + it("disambiguates two projects that share the same human-set alias", () => { + const rows = [ + row({ project_id: "project-one", project_alias: "Production" }), + row({ project_id: "project-two", project_alias: "Production" }), + ]; + + const aliases = buildProjectSpendBreakdown(rows).map((r) => r.project_alias); + expect(new Set(aliases).size).toBe(2); + expect(aliases.every((alias) => alias.includes("Production"))).toBe(true); + }); + + it("leaves a unique alias untouched", () => { + const rows = [row({ project_id: "project-alpha", project_alias: "Project Alpha" })]; + + expect(buildProjectSpendBreakdown(rows)[0].project_alias).toBe("Project Alpha"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts new file mode 100644 index 00000000000..ed385c56344 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/ProjectUsage/projectUsageAggregations.ts @@ -0,0 +1,99 @@ +import type { ProjectDailySpendRow } from "@/components/networking"; + +export interface ProjectSpendRow extends Record { + project_id: string; + project_alias: string; + spend: number; + requests: number; + successful_requests: number; + failed_requests: number; + tokens: number; +} + +export interface DailyProjectSpendPoint extends Record { + date: string; + spend: number; +} + +export interface ProjectUsageSummary { + total_spend: number; + total_api_requests: number; + total_successful_requests: number; + total_failed_requests: number; + total_tokens: number; +} + +const EMPTY_SUMMARY: ProjectUsageSummary = { + total_spend: 0, + total_api_requests: 0, + total_successful_requests: 0, + total_failed_requests: 0, + total_tokens: 0, +}; + +export const summarizeProjectUsage = (rows: ProjectDailySpendRow[]): ProjectUsageSummary => + rows.reduce( + (totals, row) => ({ + total_spend: totals.total_spend + row.spend, + total_api_requests: totals.total_api_requests + row.api_requests, + total_successful_requests: totals.total_successful_requests + row.successful_requests, + total_failed_requests: totals.total_failed_requests + row.failed_requests, + total_tokens: totals.total_tokens + row.total_tokens, + }), + EMPTY_SUMMARY, + ); + +export const buildDailySpendSeries = (rows: ProjectDailySpendRow[]): DailyProjectSpendPoint[] => { + const spendByDate = new Map(); + for (const row of rows) { + spendByDate.set(row.date, (spendByDate.get(row.date) ?? 0) + row.spend); + } + return [...spendByDate.entries()] + .map(([date, spend]) => ({ date, spend })) + .sort((a, b) => a.date.localeCompare(b.date)); +}; + +const groupByProjectId = (rows: ProjectDailySpendRow[]): ProjectDailySpendRow[][] => { + const groups = new Map(); + for (const row of rows) { + const existing = groups.get(row.project_id); + if (existing) { + existing.push(row); + } else { + groups.set(row.project_id, [row]); + } + } + return [...groups.values()]; +}; + +const summarizeProjectGroup = (rows: ProjectDailySpendRow[]): ProjectSpendRow => { + const [{ project_id, project_alias }] = rows; + const totals = rows.reduce( + (acc, row) => ({ + spend: acc.spend + row.spend, + requests: acc.requests + row.api_requests, + successful_requests: acc.successful_requests + row.successful_requests, + failed_requests: acc.failed_requests + row.failed_requests, + tokens: acc.tokens + row.total_tokens, + }), + { spend: 0, requests: 0, successful_requests: 0, failed_requests: 0, tokens: 0 }, + ); + return { project_id, project_alias: project_alias || project_id, ...totals }; +}; + +const disambiguateAliases = (rows: ProjectSpendRow[]): ProjectSpendRow[] => { + const aliasCounts = new Map(); + for (const row of rows) { + aliasCounts.set(row.project_alias, (aliasCounts.get(row.project_alias) ?? 0) + 1); + } + return rows.map((row) => + (aliasCounts.get(row.project_alias) ?? 0) > 1 + ? { ...row, project_alias: `${row.project_alias} (${row.project_id})` } + : row, + ); +}; + +export const buildProjectSpendBreakdown = (rows: ProjectDailySpendRow[]): ProjectSpendRow[] => { + const summarized = groupByProjectId(rows).map(summarizeProjectGroup); + return disambiguateAliases(summarized).sort((a, b) => b.spend - a.spend); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.test.tsx new file mode 100644 index 00000000000..33dd4d41095 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.test.tsx @@ -0,0 +1,118 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { ColumnDef } from "@tanstack/react-table"; + +import { SpendByCategoryPanel } from "./SpendByCategoryPanel"; + +vi.mock("@/components/shared/chart_loader", () => ({ + ChartLoader: ({ isDateChanging }: { isDateChanging: boolean }) => ( +
{isDateChanging ? "Processing date selection..." : "Loading chart data..."}
+ ), +})); + +interface TestRow extends Record { + key: string; + spend: number; +} + +const columns: ColumnDef[] = [{ header: "Key", accessorKey: "key" }]; + +const rows: TestRow[] = [{ key: "row-1", spend: 5 }]; + +describe("SpendByCategoryPanel", () => { + it("displays the given title", () => { + render( + row.key} + noDataMessage="No data" + />, + ); + expect(screen.getByText("Spend by Widget")).toBeInTheDocument(); + }); + + it("shows the loader instead of chart content while loading", () => { + render( + row.key} + noDataMessage="No data" + />, + ); + expect(screen.getByTestId("chart-loader")).toBeInTheDocument(); + expect(screen.queryByText("row-1")).not.toBeInTheDocument(); + }); + + it("renders the table rows once loaded", () => { + render( + row.key} + noDataMessage="No data" + />, + ); + expect(screen.getAllByText("row-1").length).toBeGreaterThan(0); + }); + + it("shows the empty message when there is no data", () => { + render( + row.key} + noDataMessage="No data for this range" + />, + ); + expect(screen.getByText("No data for this range")).toBeInTheDocument(); + }); + + it("renders a header action only when one is given", () => { + const { rerender } = render( + row.key} + noDataMessage="No data" + />, + ); + expect(screen.queryByText("Toggle")).not.toBeInTheDocument(); + + rerender( + Toggle} + loading={false} + isDateChanging={false} + data={[]} + indexKey="key" + columns={columns} + getRowId={(row) => row.key} + noDataMessage="No data" + />, + ); + expect(screen.getByText("Toggle")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.tsx new file mode 100644 index 00000000000..9dbf1550e88 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/SpendByCategoryPanel.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import type { ColumnDef } from "@tanstack/react-table"; + +import { DonutChart } from "@/components/shared/charts"; +import { DataTable } from "@/components/shared/DataTable"; +import { ChartLoader } from "@/components/shared/chart_loader"; +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; + +interface SpendByCategoryPanelProps> { + title: string; + headerAction?: React.ReactNode; + loading: boolean; + isDateChanging: boolean; + data: TRow[]; + indexKey: keyof TRow & string; + columns: ColumnDef[]; + getRowId: (row: TRow) => string; + noDataMessage: string; +} + +export function SpendByCategoryPanel>({ + title, + headerAction, + loading, + isDateChanging, + data, + indexKey, + columns, + getRowId, + noDataMessage, +}: SpendByCategoryPanelProps) { + return ( + + + {title} + {headerAction && {headerAction}} + + + {loading ? ( + + ) : ( +
+ `$${formatNumberWithCommas(value, 2)}`} + colors={["cyan"]} + showLabel + startAngle={90} + endAngle={-270} + /> + +
+ )} +
+
+ ); +} + +export default SpendByCategoryPanel; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index a92d1209567..b841d3f0a3b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -20,9 +20,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents"; import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers"; +import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import { hasCapability } from "@/utils/capabilities"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { all_admin_roles, internalUserRoles } from "@/utils/roles"; @@ -59,6 +61,7 @@ import { import EndpointUsage from "./EndpointUsage/EndpointUsage"; import EntityUsage, { EntityList } from "./EntityUsage/EntityUsage"; import ModelViewToggle, { ModelViewType } from "./ModelViewToggle"; +import ProjectUsage from "./ProjectUsage/ProjectUsage"; import SpendByProvider from "./EntityUsage/SpendByProvider"; import { TOP_MODEL_LIMITS } from "./EntityUsage/TopModelView"; import TopKeyView from "@/components/UsagePage/components/EntityUsage/TopKeyView"; @@ -102,12 +105,16 @@ const UsagePage: React.FC = ({ teams, organizations }) => { // filter reads as loading rather than as a range with no customers. const { data: customers } = useCustomers(); const { data: agentsResponse } = useAgents(); + const { data: projectsResponse } = useProjects(); const { data: currentUser } = useCurrentUser(); const isAdmin = all_admin_roles.includes(userRole || ""); const canViewTagUsage = isAdmin || internalUserRoles.includes(userRole || ""); const isOrgAdmin = useIsOrgAdmin(); const canViewOrganizationUsage = hasCapability(userRole, "viewOrganizationUsage", isOrgAdmin); const canViewAgentUsage = hasCapability(userRole, "viewAgentUsage"); + const { data: uiSettingsData } = useUISettings(); + const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui); + const canViewProjectUsage = hasCapability(userRole, "viewProjectUsage") && enableProjectsUI; // For admins: null means global view (all users), a string means filter by that user // For non-admins: always set to their own user ID @@ -117,12 +124,10 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false); const [isAiChatOpen, setIsAiChatOpen] = useState(false); const [selectedUsageView, setUsageView] = useState("global"); - // Org-admin membership is read from the server, so unlike the other usage - // views this one can be revoked while the page is open. Derive the view in - // render rather than storing it, so the fallback lands on the same paint and - // the selector never holds a value it no longer offers. - const usageView: UsageOption = - selectedUsageView === "organization" && !canViewOrganizationUsage ? "global" : selectedUsageView; + const stillHasAccessToSelectedView = + (selectedUsageView !== "organization" || canViewOrganizationUsage) && + (selectedUsageView !== "project" || canViewProjectUsage); + const usageView: UsageOption = stillHasAccessToSelectedView ? selectedUsageView : "global"; const [showCredentialBanner, setShowCredentialBanner] = useState(true); const [topKeysLimit, setTopKeysLimit] = useState(5); @@ -482,6 +487,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { userRole={userRole} canViewTagUsage={canViewTagUsage} isOrgAdmin={isOrgAdmin} + enableProjectsUI={enableProjectsUI} /> @@ -935,6 +941,20 @@ const UsagePage: React.FC = ({ teams, organizations }) => { /> )} + {usageView === "project" && canViewProjectUsage && ( + ({ + label: project.project_alias || project.project_id, + value: project.project_id, + })) || null + } + dateValue={dateValue} + premiumUser={premiumUser} + /> + )} + {/* Customer Usage Panel */} {usageView === "customer" && ( { expect(offers(container, "Tag Usage")).toBe(false); }); - it.each(["Organization Usage", "Agent Usage (A2A)"])("should show %s to an admin", async (optionName) => { - const user = userEvent.setup(); - const { container } = render(); + it.each(["Organization Usage", "Agent Usage (A2A)", "Project Usage"])( + "should show %s to an admin", + async (optionName) => { + const user = userEvent.setup(); + const { container } = render(); - await openMenu(user); - expect(offers(container, optionName)).toBe(true); - }); + await openMenu(user); + expect(offers(container, optionName)).toBe(true); + }, + ); - it.each(["Organization Usage", "Agent Usage (A2A)"])("should hide %s from an internal user", async (optionName) => { + it.each(["Organization Usage", "Agent Usage (A2A)", "Project Usage"])("should hide %s from an internal user", async (optionName) => { const user = userEvent.setup(); const { container } = render( , @@ -86,6 +89,7 @@ describe("UsageViewSelect", () => { it.each([ ["Organization Usage", true], ["Agent Usage (A2A)", false], + ["Project Usage", false], ] as const)("should offer %s to an org admin: %s", async (optionName, expected) => { const user = userEvent.setup(); const { container } = render( @@ -96,6 +100,16 @@ describe("UsageViewSelect", () => { expect(offers(container, optionName)).toBe(expected); }); + it("should hide Project Usage from an admin when enableProjectsUI is false", async () => { + const user = userEvent.setup(); + const { container } = render( + , + ); + + await openMenu(user); + expect(offers(container, "Project Usage")).toBe(false); + }); + it.each(["Team Usage", "Tag Usage"])("should keep %s available to an internal user", async (optionName) => { const user = userEvent.setup(); const { container } = render( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx index ea21a2155c1..6cf7cfb1140 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.tsx @@ -1,4 +1,4 @@ -import { BarChart3, Bot, Building2, Globe, LineChart, ShoppingCart, Tags, User, Users } from "lucide-react"; +import { BarChart3, Bot, Building2, Folder, Globe, LineChart, ShoppingCart, Tags, User, Users } from "lucide-react"; import React from "react"; import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -9,6 +9,7 @@ export type UsageOption = | "my-usage" | "organization" | "team" + | "project" | "customer" | "tag" | "agent" @@ -20,6 +21,7 @@ export interface UsageViewSelectProps { userRole: string | null; canViewTagUsage?: boolean; isOrgAdmin?: boolean; + enableProjectsUI?: boolean; title?: string; description?: string; "data-id"?: string; @@ -68,6 +70,13 @@ const OPTIONS: OptionConfig[] = [ description: "View usage by team", icon: , }, + { + value: "project", + label: "Project Usage", + description: "View usage by project", + icon: , + capability: "viewProjectUsage", + }, { value: "customer", label: "Customer Usage", @@ -110,6 +119,7 @@ export const UsageViewSelect: React.FC = ({ userRole, canViewTagUsage = false, isOrgAdmin = false, + enableProjectsUI = true, title = "Usage View", description = "Select the usage data you want to view", "data-id": dataId, @@ -117,6 +127,9 @@ export const UsageViewSelect: React.FC = ({ const isAdmin = all_admin_roles.includes(userRole ?? ""); const getFilteredOptions = () => { return OPTIONS.filter((option) => { + if (option.value === "project" && !enableProjectsUI) { + return false; + } if (option.capability) { return hasCapability(userRole, option.capability, isOrgAdmin); } diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 169b279bf79..67ddb5d2dba 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1623,6 +1623,30 @@ export const agentDailyActivityCall = async ( }); }; +export type ProjectDailySpendResponse = components["schemas"]["ProjectDailySpendResponse"]; +export type ProjectDailySpendRow = components["schemas"]["ProjectDailySpendRow"]; + +export const projectDailyActivityCall = async ( + accessToken: string, + startTime: Date, + endTime: Date, + projectIds: string[], +): Promise => { + try { + return await apiClient.get(`/project/daily/activity`, { + accessToken, + query: { + project_ids: projectIds.join(","), + start_date: formatDate(startTime), + end_date: formatDate(endTime), + }, + }); + } catch (error) { + console.error("Failed to fetch project daily activity:", error); + throw error; + } +}; + export const getOnboardingCredentials = async (inviteUUID: string) => { /** * Get all models on proxy diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx index fd7df329f33..56c5b612707 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx @@ -28,11 +28,16 @@ vi.mock("@/app/(dashboard)/hooks/projects/useProjects", () => ({ useProjects: vi.fn(), })); +vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ + useUISettings: vi.fn(), +})); + import { useInfiniteSpendLogEndUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers"; import { useInfiniteSpendLogUsers } from "@/app/(dashboard)/hooks/spendLogs/useSpendLogUsers"; import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases"; import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels"; import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects"; +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; const emptyInfiniteQuery = { data: { pages: [], pageParams: [] }, @@ -85,6 +90,9 @@ describe("RequestLogsFilters", () => { vi.mocked(useProjects).mockReturnValue({ data: [], isLoading: false } as unknown as ReturnType< typeof useProjects >); + vi.mocked(useUISettings).mockReturnValue({ + data: { values: { enable_projects_ui: true } }, + } as unknown as ReturnType); }); it("renders every backend-supported filter field", async () => { @@ -152,6 +160,17 @@ describe("RequestLogsFilters", () => { expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.PROJECT_ID, "project-1"); }); + it("hides the Project filter when the projects UI setting is disabled", async () => { + vi.mocked(useUISettings).mockReturnValue({ + data: { values: { enable_projects_ui: false } }, + } as unknown as ReturnType); + + renderFilters(); + + expect(await screen.findByText("Team ID")).toBeInTheDocument(); + expect(screen.queryByText("Project")).not.toBeInTheDocument(); + }); + it("pushes the User ID picker query to the paginated user lookup", async () => { const user = userEvent.setup(); renderFilters(); diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx index 9203edb8276..fe3e34bccdc 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx @@ -7,6 +7,7 @@ import { useInfiniteSpendLogUsers } from "@/app/(dashboard)/hooks/spendLogs/useS import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases"; import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels"; import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects"; +import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import { DataTableFilterField } from "@/components/shared/DataTable"; import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect"; import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect"; @@ -346,6 +347,8 @@ interface RequestLogsFiltersProps { export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsFiltersProps) { const valueOf = (id: string): string => asString(get(id)); const setter = (id: string) => (next: string | undefined) => set(id, next); + const { data: uiSettingsData } = useUISettings(); + const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui); return ( <> @@ -355,7 +358,9 @@ export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsF teams={teams} /> - + {enableProjectsUI && ( + + )}