This commit is contained in:
mrinal-berri 2026-10-26 12:15:56 -04:00 • committed by GitHub
commit 0828d874fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 369 additions and 2 deletions

View file

@ -12,6 +12,7 @@ import type {
ModelActivityData,
SpendMetrics,
} from "@/components/UsagePage/types";
import type { DailyActivityTagPageResponse } from "@/components/UsagePage/dailyActivityApi";
import EntityUsage from "./EntityUsage";
import { getGlobalTopKeys, getTopAPIKeys } from "./entityUsageAggregations";
@ -34,6 +35,37 @@ const emptySpendMetrics: SpendMetrics = {
cache_creation_input_tokens: 0,
};
const tagSpendRow = (
tag: string,
spend: number,
apiRequests: number,
): DailyActivityTagPageResponse["tags"][number] => ({
tag,
spend,
prompt_tokens: apiRequests * 10,
completion_tokens: apiRequests * 5,
total_tokens: apiRequests * 15,
api_requests: apiRequests,
successful_requests: apiRequests,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
});
const tagSpendPage = (offset: number): DailyActivityTagPageResponse => ({
tags:
offset === 0
? [
tagSpendRow("project:rag-v2", 0.25, 1),
tagSpendRow("cost-center:cc-42", 0.5, 2),
...Array.from({ length: 48 }, (_, index) => tagSpendRow(`tag-${index}`, 0.01, 1)),
]
: Array.from({ length: 10 }, (_, index) => tagSpendRow(`next-${index}`, 0.01, 1)),
total_tags: 60,
offset,
limit: 50,
});
const createKeyMetrics = (spend: number, metadata: KeyMetadata): KeyMetricWithMetadata => ({
metrics: { ...emptySpendMetrics, spend },
metadata,
@ -68,6 +100,7 @@ vi.mock("@/components/networking", () => ({
dailyActivityKeyPageCall: vi.fn(),
dailyActivityKeySearchCall: vi.fn(),
dailyActivityModelTopKeysCall: vi.fn(),
dailyActivityTagPageCall: vi.fn(),
dailyActivityExportCall: vi.fn(),
}));
@ -165,6 +198,7 @@ vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({
describe("EntityUsage", () => {
const mockDailyActivityAggregatedCall = vi.mocked(networking.dailyActivityAggregatedCall);
const mockDailyActivityKeyPageCall = vi.mocked(networking.dailyActivityKeyPageCall);
const mockDailyActivityTagPageCall = vi.mocked(networking.dailyActivityTagPageCall);
const mockTagDailyActivityCall = vi.fn();
const mockTeamDailyActivityCall = vi.fn();
const mockOrganizationDailyActivityCall = vi.fn();
@ -476,6 +510,7 @@ describe("EntityUsage", () => {
beforeEach(() => {
mockDailyActivityAggregatedCall.mockReset();
mockDailyActivityKeyPageCall.mockReset();
mockDailyActivityTagPageCall.mockReset();
const emptyKeyPage = {
api_keys: [],
total_api_keys: 0,
@ -483,6 +518,7 @@ describe("EntityUsage", () => {
limit: 50,
};
mockDailyActivityKeyPageCall.mockResolvedValue(emptyKeyPage);
mockDailyActivityTagPageCall.mockImplementation((request) => Promise.resolve(tagSpendPage(request.offset)));
mockDailyActivityAggregatedCall.mockImplementation((entity, request) =>
(
entityMocks[entity] as unknown as (
@ -610,6 +646,89 @@ describe("EntityUsage", () => {
expect(screen.queryByText(/^team-user-spend:/)).not.toBeInTheDocument();
});
it("shows one selected team's tag page in API order and loads the next page", async () => {
const user = userEvent.setup();
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
expect(screen.queryByTestId("team-tag-spend-card")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Team Multi Select" }));
const card = await screen.findByTestId("team-tag-spend-card");
expect(within(card).getByRole("heading", { name: "Spend Per Tag" })).toBeInTheDocument();
const rows = (await within(card).findAllByRole("row")).slice(1);
expect(rows.slice(0, 2).map((row) => within(row).getAllByRole("cell")[0].textContent)).toEqual([
"project:rag-v2",
"cost-center:cc-42",
]);
expect(within(card).getByText("Showing 1-50 of 60")).toBeInTheDocument();
expect(mockDailyActivityTagPageCall).toHaveBeenCalledWith(
expect.objectContaining({ teamId: "team-1", offset: 0, limit: 50 }),
);
await user.click(within(card).getByRole("button", { name: "Go to next page" }));
await waitFor(() =>
expect(mockDailyActivityTagPageCall).toHaveBeenCalledWith(
expect.objectContaining({ teamId: "team-1", offset: 50, limit: 50 }),
),
);
expect(await within(card).findByText("Showing 51-60 of 60")).toBeInTheDocument();
});
it("refetches the first page at the chosen size when rows per page changes", async () => {
const user = userEvent.setup();
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
await user.click(screen.getByRole("button", { name: "Team Multi Select" }));
const card = await screen.findByTestId("team-tag-spend-card");
await user.click(within(card).getByRole("button", { name: "Go to next page" }));
expect(await within(card).findByText("Showing 51-60 of 60")).toBeInTheDocument();
await user.click(within(card).getByTestId("pagination-page-size"));
await user.click(await screen.findByRole("option", { name: "100" }));
expect(await within(card).findByText("Showing 1-60 of 60")).toBeInTheDocument();
expect(mockDailyActivityTagPageCall).toHaveBeenLastCalledWith(
expect.objectContaining({ teamId: "team-1", offset: 0, limit: 100 }),
);
});
it("asks for tag spend only for exactly one team in the team view", async () => {
render(<StatefulEntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
});
expect(screen.queryByTestId("team-tag-spend-card")).not.toBeInTheDocument();
cleanup();
render(
<EntityUsage
{...defaultProps}
entityType="team"
selectedEntities={["team-1", "team-2"]}
onSelectedEntitiesChange={vi.fn()}
/>,
);
await waitFor(() => {
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
});
expect(screen.queryByTestId("team-tag-spend-card")).not.toBeInTheDocument();
cleanup();
render(
<EntityUsage
{...defaultProps}
entityType="tag"
selectedEntities={["tag-1"]}
onSelectedEntitiesChange={vi.fn()}
/>,
);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
});
expect(screen.queryByTestId("team-tag-spend-card")).not.toBeInTheDocument();
expect(mockDailyActivityTagPageCall).not.toHaveBeenCalled();
});
it("should render with organization entity type and call organization API", async () => {
render(<StatefulEntityUsage {...defaultProps} entityType="organization" />);

View file

@ -52,6 +52,7 @@ import {
} from "../overview/overviewData";
import { Panel, Segmented, Sparkline, Stat } from "../overview/Primitives";
import TopModelView from "./TopModelView";
import TeamTagSpendCard from "./TeamTagSpendCard";
import TeamUserSpendCard from "./TeamUserSpendCard";
import { ProviderSpendBreakdown } from "./SpendByProvider";
@ -356,6 +357,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
const filterSlot = entityFilterSlots[entityType];
const capitalizedEntityLabel = entityType.charAt(0).toUpperCase() + entityType.slice(1);
const tagSpendTeamId = entityType === "team" && selectedTags.length === 1 ? selectedTags[0] : null;
const showFlatCost = entityType === "team" && hasFlatCost(spendData.metadata);
const showPtuHours = entityType === "team" && hasPtuHours(spendData.metadata);
const userSpendTeamIds = useMemo(
@ -568,6 +570,10 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
/>
</Panel>
{tagSpendTeamId !== null && (
<TeamTagSpendCard accessToken={accessToken} startTime={startTime} endTime={endTime} teamId={tagSpendTeamId} />
)}
{entityType === "team" && (
<TeamUserSpendCard
accessToken={accessToken}

View file

@ -0,0 +1,178 @@
import type { ColumnDef } from "@tanstack/react-table";
import { Tags } from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { DataTable, DataTablePagination, DEFAULT_PAGE_SIZE_OPTIONS } from "@/components/shared/DataTable";
import { MoneyCell } from "@/components/shared/table_cells";
import type { DailyActivityTagPageResponse, DailyActivityTagSpendRow } from "@/components/UsagePage/dailyActivityApi";
import { Panel } from "../overview/Primitives";
import { ENTITY_API } from "./entityFetchFns";
interface TeamTagSpendCardProps {
accessToken: string | null;
startTime: Date | null;
endTime: Date | null;
teamId: string;
}
interface TagPageState {
scope: object;
pageKey: string;
response: DailyActivityTagPageResponse | null;
failed: boolean;
}
interface TeamTagSpendPage {
page: number;
pageSize: number;
loading: boolean;
failed: boolean;
rows: DailyActivityTagSpendRow[];
totalTags: number;
onPageChange: (nextPage: number) => void;
onPageSizeChange: (nextPageSize: number) => void;
}
const QUIET_HEADER = { headerClassName: "font-normal" };
const DEFAULT_PAGE_SIZE = 50;
const columns: ColumnDef<DailyActivityTagSpendRow>[] = [
{
header: "Tag",
accessorKey: "tag",
meta: QUIET_HEADER,
cell: ({ row }) => <span className="font-medium text-foreground">{row.original.tag}</span>,
},
{
header: "Spend",
accessorKey: "spend",
meta: { numeric: true, ...QUIET_HEADER },
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={4} />,
},
{
header: "Requests",
accessorKey: "api_requests",
meta: { numeric: true, ...QUIET_HEADER },
cell: ({ row }) => row.original.api_requests.toLocaleString(),
},
{
header: "Successful",
accessorKey: "successful_requests",
meta: { numeric: true, className: "text-success", ...QUIET_HEADER },
cell: ({ row }) => row.original.successful_requests.toLocaleString(),
},
{
header: "Failed",
accessorKey: "failed_requests",
meta: { numeric: true, className: "text-destructive", ...QUIET_HEADER },
cell: ({ row }) => row.original.failed_requests.toLocaleString(),
},
{
header: "Tokens",
accessorKey: "total_tokens",
meta: { numeric: true, ...QUIET_HEADER },
cell: ({ row }) => row.original.total_tokens.toLocaleString(),
},
];
const useTeamTagSpendPage = ({ accessToken, startTime, endTime, teamId }: TeamTagSpendCardProps): TeamTagSpendPage => {
const startTimestamp = startTime?.getTime();
const endTimestamp = endTime?.getTime();
const requestScope = useMemo(
() => ({ hasAccessToken: accessToken !== null, endTimestamp, startTimestamp, teamId }),
[accessToken, endTimestamp, startTimestamp, teamId],
);
const [pageState, setPageState] = useState<{ scope: object; pageSize: number; page: number }>({
scope: {},
pageSize: DEFAULT_PAGE_SIZE,
page: 0,
});
const [tagPageState, setTagPageState] = useState<TagPageState | null>(null);
const pageSize = pageState.pageSize;
const page = pageState.scope === requestScope ? pageState.page : 0;
const pageKey = `${page}:${pageSize}`;
const currentScopeState = tagPageState?.scope === requestScope ? tagPageState : null;
const currentPageState = currentScopeState?.pageKey === pageKey ? currentScopeState : null;
const enabled = Boolean(accessToken && startTimestamp !== undefined && endTimestamp !== undefined);
useEffect(() => {
if (!accessToken || startTimestamp === undefined || endTimestamp === undefined) return;
const controller = new AbortController();
const tagPageRequest = {
accessToken,
startTime: new Date(startTimestamp),
endTime: new Date(endTimestamp),
teamId,
offset: page * pageSize,
limit: pageSize,
signal: controller.signal,
};
void ENTITY_API.team
.tagPage(tagPageRequest)
.then((response) => {
if (controller.signal.aborted) return;
const nextPageState: TagPageState = { scope: requestScope, pageKey, response, failed: false };
setTagPageState(nextPageState);
})
.catch((error: unknown) => {
if (controller.signal.aborted) return;
console.error("Team tag spend page request failed:", error);
setTagPageState((current) => ({
scope: requestScope,
pageKey,
response: current?.scope === requestScope ? current.response : null,
failed: true,
}));
});
return () => controller.abort();
}, [accessToken, endTimestamp, page, pageKey, pageSize, requestScope, startTimestamp, teamId]);
const loading = enabled && currentPageState === null;
const failed = currentPageState?.failed ?? false;
const rows = failed ? [] : currentPageState?.response?.tags ?? [];
const totalTags = currentScopeState?.response?.total_tags ?? 0;
const onPageChange = (nextPage: number) => setPageState({ scope: requestScope, pageSize, page: nextPage });
const onPageSizeChange = (nextPageSize: number) =>
setPageState({ scope: requestScope, pageSize: nextPageSize, page: 0 });
return { page, pageSize, loading, failed, rows, totalTags, onPageChange, onPageSizeChange };
};
const TeamTagSpendCard: React.FC<TeamTagSpendCardProps> = (props) => {
const { page, pageSize, loading, failed, rows, totalTags, onPageChange, onPageSizeChange } =
useTeamTagSpendPage(props);
return (
<Panel icon={Tags} title="Spend Per Tag" testId="team-tag-spend-card">
<p className="mb-3 text-xs text-muted-foreground">
A request with several tags counts once per tag, so the rows can add up to more than the team total
</p>
<DataTable
columns={columns}
data={rows}
getRowId={(row) => row.tag}
isLoading={loading}
maxBodyHeight={280}
noDataMessage={
failed ? "Could not load tag spend. Reload the page to try again" : "No tagged spend in this range"
}
size="compact"
/>
{totalTags > 0 && (
<DataTablePagination
page={page}
pageSize={pageSize}
rowCount={totalTags}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
pageSizeOptions={DEFAULT_PAGE_SIZE_OPTIONS}
isLoading={loading}
/>
)}
</Panel>
);
};
export default TeamTagSpendCard;

View file

@ -4,6 +4,7 @@ import {
dailyActivityKeyPageCall,
dailyActivityKeySearchCall,
dailyActivityModelTopKeysCall,
dailyActivityTagPageCall,
} from "@/components/networking";
import type { EntityType } from "@/components/EntityUsageExport/types";
import type {
@ -12,6 +13,8 @@ import type {
DailyActivityKeySearchResponse,
DailyActivityKeyPageResponse,
DailyActivityRequest,
DailyActivityTagPageRequest,
DailyActivityTagPageResponse,
ExportFormat,
ExportType,
ModelTopKeysResponse,
@ -30,6 +33,10 @@ export interface EntityApi {
exportRows(req: DailyActivityRequest, exportType: ExportType, format: ExportFormat): Promise<Blob>;
}
export interface TeamEntityApi extends EntityApi {
tagPage(req: DailyActivityTagPageRequest): Promise<DailyActivityTagPageResponse>;
}
const entityApi = (
entity: DailyActivityEntity,
defaults?: Pick<DailyActivityRequest, "excludeEntityIds">,
@ -42,9 +49,12 @@ const entityApi = (
exportRows: (req, exportType, format) => dailyActivityExportCall(entity, { ...defaults, ...req }, exportType, format),
});
export const ENTITY_API: Record<EntityType, EntityApi> = {
export const ENTITY_API: Record<EntityType, EntityApi> & { team: TeamEntityApi } = {
tag: entityApi("tag"),
team: entityApi("team", { excludeEntityIds: ["litellm-dashboard"] }),
team: {
...entityApi("team", { excludeEntityIds: ["litellm-dashboard"] }),
tagPage: dailyActivityTagPageCall,
},
organization: entityApi("organization"),
customer: entityApi("customer"),
agent: entityApi("agent"),

View file

@ -77,6 +77,7 @@ beforeAll(() => {
vi.mock("@/components/networking", () => ({
dailyActivityAggregatedCall: vi.fn(),
dailyActivityKeyPageCall: vi.fn(),
dailyActivityTagPageCall: vi.fn(),
userDailyActivityUserPageCall: vi.fn(),
dailyActivityKeySearchCall: vi.fn(),
dailyActivityModelTopKeysCall: vi.fn(),

View file

@ -11,11 +11,23 @@ export type KeyActivityRow = components["schemas"]["KeyActivityRow"];
export type KeySpendActivityRow = components["schemas"]["KeySpendActivityRow"];
export type DailyActivityKeySearchResponse = components["schemas"]["DailyActivityKeySearchResponse"];
export type DailyActivityKeyPageResponse = components["schemas"]["DailyActivityKeyPageResponse"];
export type DailyActivityTagPageResponse = components["schemas"]["DailyActivityTagPageResponse"];
export type DailyActivityTagSpendRow = components["schemas"]["TagSpendRow"];
export type ModelTopKeysResponse = components["schemas"]["ModelTopKeysResponse"];
export type CacheLeakageKeysResponse = components["schemas"]["CacheLeakageKeysResponse"];
export type UserActivityRow = components["schemas"]["UserActivityRow"];
export type DailyActivityUserPageResponse = components["schemas"]["DailyActivityUserPageResponse"];
export interface DailyActivityTagPageRequest {
accessToken: string;
startTime: Date;
endTime: Date;
teamId: string;
offset: number;
limit: number;
signal?: AbortSignal;
}
export interface DailyActivityRequest {
accessToken: string;
startTime: Date;

View file

@ -6,6 +6,7 @@ import {
dailyActivityExportCall,
dailyActivityKeySearchCall,
dailyActivityModelTopKeysCall,
dailyActivityTagPageCall,
} from "./networking";
import type { DailyActivityEntity, DailyActivityRequest } from "./UsagePage/dailyActivityApi";
@ -62,6 +63,7 @@ describe("dailyActivityAggregatedCall", () => {
expect(url.searchParams.has("timezone")).toBe(true);
expect(url.searchParams.has("page")).toBe(false);
expect(url.searchParams.has("page_size")).toBe(false);
expect(url.searchParams.has("group_by")).toBe(false);
});
it("omits the entity filter when no ids are given", async () => {
@ -123,6 +125,29 @@ describe("dailyActivityAggregatedCall", () => {
expect(requestedUrl(mockFetch).searchParams.get("include_current_utc_day")).toBe("true");
});
it("GETs the team tags page with team, offset, and limit", async () => {
const mockFetch = captureFetch();
const request = {
accessToken: "sk-key",
startTime: start,
endTime: end,
teamId: "team-a",
offset: 50,
limit: 50,
};
await dailyActivityTagPageCall(request);
const url = requestedUrl(mockFetch);
expect(url.pathname).toBe("/team/daily/activity/aggregated/tags");
expect(url.searchParams.get("team_ids")).toBe("team-a");
expect(url.searchParams.get("start_date")).toBe("2025-01-05");
expect(url.searchParams.get("end_date")).toBe("2025-01-31");
expect(url.searchParams.get("offset")).toBe("50");
expect(url.searchParams.get("limit")).toBe("50");
expect(url.searchParams.has("group_by")).toBe(false);
});
it("sends api_key_limit only when provided", async () => {
const mockFetch = captureFetch();

View file

@ -144,6 +144,8 @@ import type {
DailyActivityKeyPageResponse,
DailyActivityKeySearchResponse,
DailyActivityRequest,
DailyActivityTagPageRequest,
DailyActivityTagPageResponse,
DailyActivityUserPageResponse,
ExportFormat,
ExportType,
@ -1405,6 +1407,20 @@ export const dailyActivityKeyPageCall = (
query: dailyActivityQuery(entity, req, { offset, limit }),
});
export const dailyActivityTagPageCall = (req: DailyActivityTagPageRequest): Promise<DailyActivityTagPageResponse> => {
const dailyActivityRequest: DailyActivityRequest = {
accessToken: req.accessToken,
startTime: req.startTime,
endTime: req.endTime,
entityIds: [req.teamId],
};
return apiClient.get<DailyActivityTagPageResponse>(`/team/daily/activity/aggregated/tags`, {
accessToken: req.accessToken,
query: dailyActivityQuery("team", dailyActivityRequest, { offset: req.offset, limit: req.limit }),
...(req.signal ? { signal: req.signal } : {}),
});
};
export const dailyActivityKeySearchCall = (
entity: DailyActivityEntity,
req: DailyActivityRequest,