fix: Gate Project Usage behind the enable_projects_ui setting (BerriAI/litellm/issues/40386)

Usage page and the logs filters showed the Project Usage view and Project
filter unconditionally, unlike the sidebar, key edit, key info, and
create-key surfaces which already respect enable_projects_ui

Also format the project-not-found 404 message as a sorted, comma-joined
list instead of a raw Python list repr, and drop the frontend workaround
that parsed it back out

Extract SummaryTileCard as a component shared between EntityUsage and
ProjectUsage, and move ProjectUsage's derived data behind useMemo ahead
of its early return so hook order stays stable across renders
This commit is contained in:
Aryan Gupta 2026-09-11 12:17:44 +05:30
parent 05ef5627ae
commit a51066f79e
14 changed files with 157 additions and 108 deletions

View file

@ -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:

View file

@ -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()

View file

@ -17,7 +17,7 @@ export const useUISettings = (options?: { staleTime?: number; refetchInterval?:
return useQuery<Record<string, any>>({
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,
});

View file

@ -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<EntityUsageProps> = ({
[],
);
const chev = "size-3 text-muted-foreground";
const expandIcon = showCostBreakdown ? <ChevronDown className={chev} /> : <ChevronRight className={chev} />;
const renderSummaryTile = ({ title, value, className, tooltip, expandable }: SummaryTile) => (
<ShadcnCard
key={title}
className={expandable ? "cursor-pointer hover:bg-accent transition-colors" : undefined}
onClick={expandable ? () => setShowCostBreakdown(!showCostBreakdown) : undefined}
>
<CardContent>
<div className="flex items-center gap-2">
<h3 className="text-lg font-medium text-foreground">{title}</h3>
{tooltip ? (
<Tooltip>
<TooltipTrigger render={<Info className="size-4 text-muted-foreground hover:text-foreground" />} />
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
) : null}
{expandable ? expandIcon : null}
</div>
<p className={`text-2xl font-bold mt-2 ${className ?? ""}`}>{value}</p>
</CardContent>
</ShadcnCard>
const renderSummaryTile = (tile: SummaryTile) => (
<SummaryTileCard
key={tile.title}
tile={tile}
expanded={showCostBreakdown}
onToggleExpand={() => setShowCostBreakdown(!showCostBreakdown)}
/>
);
const breakdownTiles = showFlatCost && showCostBreakdown ? buildCostBreakdownTiles(spendData.metadata) : [];

View file

@ -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 ? <ChevronDown className={chev} /> : <ChevronRight className={chev} />;
return (
<ShadcnCard
className={expandable ? "cursor-pointer hover:bg-accent transition-colors" : undefined}
onClick={expandable ? onToggleExpand : undefined}
>
<CardContent>
<div className="flex items-center gap-2">
<h3 className="text-lg font-medium text-foreground">{title}</h3>
{tooltip ? (
<Tooltip>
<TooltipTrigger render={<Info className="size-4 text-muted-foreground hover:text-foreground" />} />
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
) : null}
{expandable ? expandIcon : null}
</div>
<p className={`text-2xl font-bold mt-2 ${className ?? ""}`}>{value}</p>
</CardContent>
</ShadcnCard>
);
}
export default SummaryTileCard;

View file

@ -73,12 +73,9 @@ describe("ProjectUsage", () => {
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"],
);
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);
@ -101,6 +98,20 @@ describe("ProjectUsage", () => {
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(
<ProjectUsage accessToken="test-token" projectList={PROJECT_LIST} dateValue={DATE_VALUE} premiumUser={true} />,
);
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

View file

@ -13,15 +13,11 @@ import { projectDailyActivityCall } from "@/components/networking";
import { extractProxyErrorMessage } from "@/lib/http/client";
import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters";
import { buildSummaryTiles, type SummaryTile } from "../EntityUsage/entityUsageSummary";
import { buildSummaryTiles } from "../EntityUsage/entityUsageSummary";
import { SummaryTileCard } from "../EntityUsage/SummaryTileCard";
import type { EntityList } from "../EntityUsage/EntityUsage";
import ProjectSpendBreakdown from "./ProjectSpendBreakdown";
import {
buildDailySpendSeries,
buildProjectSpendBreakdown,
humanizeBackendListMessage,
summarizeProjectUsage,
} from "./projectUsageAggregations";
import { buildDailySpendSeries, buildProjectSpendBreakdown, summarizeProjectUsage } from "./projectUsageAggregations";
interface ProjectUsageProps {
accessToken: string | null;
@ -30,15 +26,6 @@ interface ProjectUsageProps {
premiumUser: boolean;
}
const renderSummaryTile = ({ title, value, className }: SummaryTile) => (
<ShadcnCard key={title}>
<CardContent>
<h3 className="text-lg font-medium text-foreground">{title}</h3>
<p className={`text-2xl font-bold mt-2 ${className ?? ""}`}>{value}</p>
</CardContent>
</ShadcnCard>
);
const ProjectUsage: React.FC<ProjectUsageProps> = ({ accessToken, projectList, dateValue, premiumUser }) => {
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
@ -57,12 +44,18 @@ const ProjectUsage: React.FC<ProjectUsageProps> = ({ accessToken, projectList, d
const queryOptions = {
queryKey: ["project-daily-activity", selectedProjectIds, startTime?.toISOString(), endTime?.toISOString()],
queryFn: () => projectDailyActivityCall(accessToken as string, startTime as Date, endTime as Date, selectedProjectIds),
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 (
<Alert variant="info">
@ -78,10 +71,6 @@ const ProjectUsage: React.FC<ProjectUsageProps> = ({ accessToken, projectList, d
);
}
const rows = data?.results ?? [];
const summary = summarizeProjectUsage(rows);
const dailySpend = buildDailySpendSeries(rows);
const projectBreakdown = buildProjectSpendBreakdown(rows);
const isLoadingRows = isPending || (isFetching && isPlaceholderData);
const renderResultsPanel = () => {
@ -104,7 +93,7 @@ const ProjectUsage: React.FC<ProjectUsageProps> = ({ accessToken, projectList, d
<div className="col-span-2">
<Alert variant="error">
<AlertTitle>Could not load project usage</AlertTitle>
<AlertDescription>{humanizeBackendListMessage(extractProxyErrorMessage(error))}</AlertDescription>
<AlertDescription>{extractProxyErrorMessage(error)}</AlertDescription>
</Alert>
</div>
);
@ -120,7 +109,9 @@ const ProjectUsage: React.FC<ProjectUsageProps> = ({ accessToken, projectList, d
<ChartLoader isDateChanging={false} />
) : (
<div className="grid grid-cols-5 gap-4 mt-4">
{buildSummaryTiles(summary, false).map(renderSummaryTile)}
{buildSummaryTiles(summary, false).map((tile) => (
<SummaryTileCard key={tile.title} tile={tile} />
))}
</div>
)}
</CardContent>

View file

@ -2,12 +2,7 @@ import { describe, expect, it } from "vitest";
import type { ProjectDailySpendRow } from "@/components/networking";
import {
buildDailySpendSeries,
buildProjectSpendBreakdown,
humanizeBackendListMessage,
summarizeProjectUsage,
} from "./projectUsageAggregations";
import { buildDailySpendSeries, buildProjectSpendBreakdown, summarizeProjectUsage } from "./projectUsageAggregations";
const row = (overrides: Partial<ProjectDailySpendRow> = {}): ProjectDailySpendRow => ({
date: "2026-09-01",
@ -126,23 +121,3 @@ describe("buildProjectSpendBreakdown", () => {
expect(buildProjectSpendBreakdown(rows)[0].project_alias).toBe("Project Alpha");
});
});
describe("humanizeBackendListMessage", () => {
it("strips a single-quoted Python list down to plain text", () => {
expect(humanizeBackendListMessage("Project(s) not found: ['proj-123']")).toBe(
"Project(s) not found: proj-123",
);
});
it("comma-joins a multi-item Python list", () => {
expect(humanizeBackendListMessage("Project(s) not found: ['proj-1', 'proj-2']")).toBe(
"Project(s) not found: proj-1, proj-2",
);
});
it("leaves a message with no trailing list untouched", () => {
expect(humanizeBackendListMessage("Not authorized to view this project")).toBe(
"Not authorized to view this project",
);
});
});

View file

@ -44,20 +44,27 @@ export const summarizeProjectUsage = (rows: ProjectDailySpendRow[]): ProjectUsag
);
export const buildDailySpendSeries = (rows: ProjectDailySpendRow[]): DailyProjectSpendPoint[] => {
const spendByDate = rows.reduce<Record<string, number>>(
(totals, row) => ({ ...totals, [row.date]: (totals[row.date] ?? 0) + row.spend }),
{},
);
return Object.entries(spendByDate)
const spendByDate = new Map<string, number>();
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[]): Record<string, ProjectDailySpendRow[]> =>
rows.reduce<Record<string, ProjectDailySpendRow[]>>(
(groups, row) => ({ ...groups, [row.project_id]: [...(groups[row.project_id] ?? []), row] }),
{},
);
const groupByProjectId = (rows: ProjectDailySpendRow[]): ProjectDailySpendRow[][] => {
const groups = new Map<string, ProjectDailySpendRow[]>();
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;
@ -75,22 +82,18 @@ const summarizeProjectGroup = (rows: ProjectDailySpendRow[]): ProjectSpendRow =>
};
const disambiguateAliases = (rows: ProjectSpendRow[]): ProjectSpendRow[] => {
const aliasCounts = rows.reduce<Record<string, number>>(
(counts, row) => ({ ...counts, [row.project_alias]: (counts[row.project_alias] ?? 0) + 1 }),
{},
);
const aliasCounts = new Map<string, number>();
for (const row of rows) {
aliasCounts.set(row.project_alias, (aliasCounts.get(row.project_alias) ?? 0) + 1);
}
return rows.map((row) =>
aliasCounts[row.project_alias] > 1 ? { ...row, project_alias: `${row.project_alias} (${row.project_id})` } : 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 = Object.values(groupByProjectId(rows)).map(summarizeProjectGroup);
const summarized = groupByProjectId(rows).map(summarizeProjectGroup);
return disambiguateAliases(summarized).sort((a, b) => b.spend - a.spend);
};
export const humanizeBackendListMessage = (message: string): string =>
message.replace(/\[([^\]]*)]\s*$/, (_match, listContents: string) => {
const items = [...listContents.matchAll(/'([^']*)'|"([^"]*)"/g)].map((m) => m[1] ?? m[2]);
return items.length > 0 ? items.join(", ") : listContents;
});

View file

@ -24,6 +24,7 @@ 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";
@ -111,7 +112,9 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const isOrgAdmin = useIsOrgAdmin();
const canViewOrganizationUsage = hasCapability(userRole, "viewOrganizationUsage", isOrgAdmin);
const canViewAgentUsage = hasCapability(userRole, "viewAgentUsage");
const canViewProjectUsage = hasCapability(userRole, "viewProjectUsage");
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
@ -484,6 +487,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
userRole={userRole}
canViewTagUsage={canViewTagUsage}
isOrgAdmin={isOrgAdmin}
enableProjectsUI={enableProjectsUI}
/>
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
</div>
@ -937,7 +941,6 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
/>
)}
{/* Project Usage Panel */}
{usageView === "project" && canViewProjectUsage && (
<ProjectUsage
accessToken={accessToken}

View file

@ -100,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(
<UsageViewSelect value="global" onChange={mockOnChange} userRole="Admin" enableProjectsUI={false} />,
);
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(

View file

@ -21,6 +21,7 @@ export interface UsageViewSelectProps {
userRole: string | null;
canViewTagUsage?: boolean;
isOrgAdmin?: boolean;
enableProjectsUI?: boolean;
title?: string;
description?: string;
"data-id"?: string;
@ -118,6 +119,7 @@ export const UsageViewSelect: React.FC<UsageViewSelectProps> = ({
userRole,
canViewTagUsage = false,
isOrgAdmin = false,
enableProjectsUI = true,
title = "Usage View",
description = "Select the usage data you want to view",
"data-id": dataId,
@ -125,6 +127,9 @@ export const UsageViewSelect: React.FC<UsageViewSelectProps> = ({
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);
}

View file

@ -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<typeof useUISettings>);
});
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<typeof useUISettings>);
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();

View file

@ -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}
/>
<ProjectFilterField value={valueOf(LOG_FILTER_IDS.PROJECT_ID)} onChange={setter(LOG_FILTER_IDS.PROJECT_ID)} />
{enableProjectsUI && (
<ProjectFilterField value={valueOf(LOG_FILTER_IDS.PROJECT_ID)} onChange={setter(LOG_FILTER_IDS.PROJECT_ID)} />
)}
<DataTableFilterField label="Status">
<Select