mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-31 06:56:59 +00:00
refactor(ui): remove the deprecated Old Usage page (#49284)
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
24b8889965
commit
1ccb970d52
17 changed files with 4 additions and 1453 deletions
|
|
@ -32,7 +32,6 @@ export const menuLabelToPage: Record<string, Page> = {
|
|||
Budgets: Page.Budgets,
|
||||
"API Playground": Page.TransformRequest,
|
||||
"Tag Management": Page.TagManagement,
|
||||
"Old Usage": Page.Usage,
|
||||
// Tools submenu items
|
||||
"MCP Servers": Page.McpServers,
|
||||
"Vector Stores": Page.VectorStores,
|
||||
|
|
|
|||
|
|
@ -118,12 +118,6 @@ export const MIGRATED_E2E_PAGES: Readonly<Record<string, MigratedPage>> = {
|
|||
content: { role: "heading", name: "AI Hub" },
|
||||
},
|
||||
new_usage: { segment: "usage", linkName: "Usage", content: { role: "heading", name: "Usage" } },
|
||||
usage: {
|
||||
segment: "old-usage",
|
||||
linkName: "Old Usage",
|
||||
group: "Experimental",
|
||||
content: { role: "tab", name: "All Up" },
|
||||
},
|
||||
agents: { segment: "agents", linkName: "Agents", group: "Agentic", content: { role: "heading", name: "Agents" } },
|
||||
"router-settings": {
|
||||
segment: "router-settings",
|
||||
|
|
|
|||
|
|
@ -30,5 +30,4 @@ export enum Page {
|
|||
TagManagement = "tag-management",
|
||||
VectorStores = "vector-stores",
|
||||
NewUsage = "new_usage",
|
||||
Usage = "usage",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ test.describe("Logs pagination with Aria in the header", () => {
|
|||
await expect(aria).toBeFocused();
|
||||
await visibleTestId(page, "pagination-first").click();
|
||||
await expect(visibleTestId(page, "pagination-range")).toHaveText("Showing 1-25 of 26");
|
||||
for (const destination of [Page.Usage, Page.LlmPlayground, Page.ApiKeys]) {
|
||||
for (const destination of [Page.NewUsage, Page.LlmPlayground, Page.ApiKeys]) {
|
||||
await navigateToPage(page, destination);
|
||||
await aria.click();
|
||||
await expect(page.getByRole("dialog", { name: "Aria Agent", exact: true })).toBeVisible();
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
waitForSpendLog,
|
||||
} from "../../helpers/traffic";
|
||||
|
||||
/** Covers /ui/usage. The legacy /ui/old-usage view is deprecated and deliberately not covered. */
|
||||
/** Covers /ui/usage. */
|
||||
|
||||
const topKeysCard = (page: PlaywrightPage): Locator =>
|
||||
page.locator("section").filter({ has: page.getByRole("heading", { name: "Top Virtual Keys", exact: true }) });
|
||||
|
|
|
|||
|
|
@ -625,23 +625,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/old-usage/_components/usage.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"max-lines": {
|
||||
"count": 1
|
||||
},
|
||||
"prefer-const": {
|
||||
"count": 6
|
||||
},
|
||||
"react-hooks/immutability": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/purity": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
|
|
@ -2090,17 +2073,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_user_spend.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"prefer-const": {
|
||||
"count": 3
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/contexts/AuthContext.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ describe("legacyPageRedirectHref", () => {
|
|||
expect(redirect("page=models")).toBe("/ui/models-and-endpoints");
|
||||
expect(redirect("page=llm-playground")).toBe("/ui/playground");
|
||||
expect(redirect("page=new_usage")).toBe("/ui/usage");
|
||||
expect(redirect("page=usage")).toBe("/ui/old-usage");
|
||||
expect(redirect("page=usage")).toBe("/ui/usage");
|
||||
});
|
||||
|
||||
it("keeps the older aliases for renamed pages", () => {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ const LEGACY_PAGE_ROUTES: ReadonlyMap<string, string> = new Map(
|
|||
"logging-and-alerts": "logging-and-alerts",
|
||||
"model-hub-table": "model-hub-table",
|
||||
new_usage: "usage",
|
||||
usage: "old-usage",
|
||||
usage: "usage",
|
||||
"cost-optimization": "cost-optimization",
|
||||
"model-insights": "model-insights",
|
||||
agents: "agents",
|
||||
|
|
|
|||
|
|
@ -1,257 +0,0 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "../../../../../tests/test-utils";
|
||||
import UsagePage from "./usage";
|
||||
|
||||
const networking = vi.hoisted(() => ({
|
||||
adminSpendLogsCall: vi.fn(),
|
||||
adminTopKeysCall: vi.fn(),
|
||||
adminTopModelsCall: vi.fn(),
|
||||
adminTopEndUsersCall: vi.fn(),
|
||||
teamSpendLogsCall: vi.fn(),
|
||||
tagsSpendLogsCall: vi.fn(),
|
||||
allTagNamesCall: vi.fn(),
|
||||
adminspendByProvider: vi.fn(),
|
||||
adminGlobalActivity: vi.fn(),
|
||||
adminGlobalActivityPerModel: vi.fn(),
|
||||
getProxyUISettings: vi.fn(),
|
||||
modelAvailableCall: vi.fn(),
|
||||
keyInfoV1Call: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/networking", () => networking);
|
||||
vi.mock("../../../../components/networking", () => networking);
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: () => ({
|
||||
accessToken: "sk-test",
|
||||
token: "tok",
|
||||
userRole: "Admin",
|
||||
userId: "u1",
|
||||
premiumUser: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
const UNLIMITED_SETTINGS = { DISABLE_EXPENSIVE_DB_QUERIES: false, NUM_SPEND_LOGS_ROWS: 10 };
|
||||
|
||||
const renderUsage = (overrides: Partial<React.ComponentProps<typeof UsagePage>> = {}) =>
|
||||
renderWithProviders(
|
||||
<UsagePage
|
||||
accessToken="sk-test"
|
||||
token="tok"
|
||||
userRole="Admin"
|
||||
userID="u1"
|
||||
keys={null}
|
||||
premiumUser={true}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Width of this window is guarded by "proves the flush window is wide enough".
|
||||
const flushPendingRequests = async () => {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
networking.getProxyUISettings.mockResolvedValue(UNLIMITED_SETTINGS);
|
||||
networking.adminSpendLogsCall.mockResolvedValue([{ date: "2026-07-01", spend: 12.5 }]);
|
||||
networking.adminTopKeysCall.mockResolvedValue([
|
||||
{ api_key: "sk-abcdefghijk", key_alias: "prod-key", total_spend: 9.5 },
|
||||
]);
|
||||
networking.adminTopModelsCall.mockResolvedValue([{ model: "gpt-5.1", total_spend: 7.25 }]);
|
||||
networking.adminTopEndUsersCall.mockResolvedValue([
|
||||
{ end_user: "customer-alpha", total_spend: 3.5, total_count: 42 },
|
||||
]);
|
||||
networking.teamSpendLogsCall.mockResolvedValue({
|
||||
daily_spend: [{ date: "2026-07-01", "team-a": 5 }],
|
||||
teams: ["team-a"],
|
||||
total_spend_per_team: [{ team_id: "team-a", total_spend: 5 }],
|
||||
});
|
||||
networking.tagsSpendLogsCall.mockResolvedValue({ spend_per_tag: [{ name: "prod", spend: 4 }] });
|
||||
networking.allTagNamesCall.mockResolvedValue({ tag_names: ["prod", "staging"] });
|
||||
networking.adminspendByProvider.mockResolvedValue([{ provider: "openai", spend: 6.75 }]);
|
||||
networking.adminGlobalActivity.mockResolvedValue({
|
||||
sum_api_requests: 120,
|
||||
sum_total_tokens: 4500,
|
||||
daily_data: [{ date: "2026-07-01", api_requests: 120, total_tokens: 4500 }],
|
||||
});
|
||||
networking.adminGlobalActivityPerModel.mockResolvedValue([]);
|
||||
networking.modelAvailableCall.mockResolvedValue({ data: [] });
|
||||
networking.keyInfoV1Call.mockResolvedValue({ info: {} });
|
||||
});
|
||||
|
||||
describe("old usage page", () => {
|
||||
describe("when the proxy has disabled expensive DB queries", () => {
|
||||
beforeEach(() => {
|
||||
networking.getProxyUISettings.mockResolvedValue({
|
||||
DISABLE_EXPENSIVE_DB_QUERIES: true,
|
||||
NUM_SPEND_LOGS_ROWS: 2500000,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the database query limit warning instead of the usage dashboard", async () => {
|
||||
renderUsage();
|
||||
|
||||
expect(await screen.findByText("Database Query Limit Reached")).toBeInTheDocument();
|
||||
expect(screen.getByText(/SpendLogs in DB has/)).toHaveTextContent("2500000");
|
||||
expect(screen.getByText(/Please follow our guide to view usage when SpendLogs has more than 1M rows/i));
|
||||
expect(screen.queryByRole("tab", { name: "All Up" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("links to the cost tracking guide in a new tab", async () => {
|
||||
renderUsage();
|
||||
|
||||
const link = await screen.findByRole("link", { name: "View Usage Guide" });
|
||||
expect(link).toHaveAttribute("href", "https://docs.litellm.ai/docs/proxy/cost_tracking");
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
|
||||
it("skips every expensive usage query", async () => {
|
||||
renderUsage();
|
||||
|
||||
await screen.findByText("Database Query Limit Reached");
|
||||
await waitFor(() => expect(networking.getProxyUISettings).toHaveBeenCalled());
|
||||
|
||||
expect(networking.adminSpendLogsCall).not.toHaveBeenCalled();
|
||||
expect(networking.adminspendByProvider).not.toHaveBeenCalled();
|
||||
expect(networking.adminTopKeysCall).not.toHaveBeenCalled();
|
||||
expect(networking.adminTopModelsCall).not.toHaveBeenCalled();
|
||||
expect(networking.adminGlobalActivity).not.toHaveBeenCalled();
|
||||
expect(networking.adminGlobalActivityPerModel).not.toHaveBeenCalled();
|
||||
expect(networking.teamSpendLogsCall).not.toHaveBeenCalled();
|
||||
expect(networking.adminTopEndUsersCall).not.toHaveBeenCalled();
|
||||
expect(networking.tagsSpendLogsCall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("as an admin", () => {
|
||||
it("renders the admin tabs", async () => {
|
||||
renderUsage();
|
||||
|
||||
expect(await screen.findByRole("tab", { name: "All Up" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Team Based Usage" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Customer Usage" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Tag Based Usage" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the cost panel cards", async () => {
|
||||
renderUsage();
|
||||
|
||||
expect(await screen.findByText("Monthly Spend")).toBeInTheDocument();
|
||||
expect(screen.getByText("Top Virtual Keys")).toBeInTheDocument();
|
||||
expect(screen.getByText("Top Models")).toBeInTheDocument();
|
||||
expect(screen.getByText("Spend by Provider")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists spend by provider in a table", async () => {
|
||||
renderUsage();
|
||||
|
||||
const providerCell = await screen.findByText("openai");
|
||||
const row = providerCell.closest("tr");
|
||||
expect(row).not.toBeNull();
|
||||
expect(within(row as HTMLElement).getByText("$6.75")).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Provider" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the customer usage table when its tab is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderUsage();
|
||||
|
||||
await user.click(await screen.findByRole("tab", { name: "Customer Usage" }));
|
||||
|
||||
const customerCell = await screen.findByText("customer-alpha");
|
||||
const row = customerCell.closest("tr");
|
||||
expect(row).not.toBeNull();
|
||||
expect(within(row as HTMLElement).getByText("$3.50")).toBeInTheDocument();
|
||||
expect(within(row as HTMLElement).getByText("42")).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "Total Events" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the tag spend panel when its tab is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderUsage();
|
||||
|
||||
await user.click(await screen.findByRole("tab", { name: "Tag Based Usage" }));
|
||||
|
||||
expect(await screen.findByText("Spend Per Tag")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the team spend panel when its tab is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderUsage();
|
||||
|
||||
await user.click(await screen.findByRole("tab", { name: "Team Based Usage" }));
|
||||
|
||||
expect(await screen.findByText("Total Spend Per Team")).toBeInTheDocument();
|
||||
expect(screen.getByText("Daily Spend Per Team")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// org_admin is an organization membership role; those users reach the UI as "Internal User".
|
||||
describe.each(["Internal User", "Internal Viewer", "internal_user", "internal_user_viewer", "Org Admin"])(
|
||||
"as %s",
|
||||
(userRole) => {
|
||||
it("shows the admin-only notice instead of the usage dashboard", async () => {
|
||||
renderUsage({ userRole });
|
||||
|
||||
expect(await screen.findByText(/Proxy-wide usage is only available to admin users/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: "All Up" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fires no /global/spend or /global/activity request", async () => {
|
||||
renderUsage({ userRole });
|
||||
|
||||
await screen.findByText(/Proxy-wide usage is only available to admin users/i);
|
||||
await flushPendingRequests();
|
||||
|
||||
expect(networking.getProxyUISettings).not.toHaveBeenCalled();
|
||||
expect(networking.adminSpendLogsCall).not.toHaveBeenCalled();
|
||||
expect(networking.adminTopKeysCall).not.toHaveBeenCalled();
|
||||
expect(networking.adminTopModelsCall).not.toHaveBeenCalled();
|
||||
expect(networking.adminTopEndUsersCall).not.toHaveBeenCalled();
|
||||
expect(networking.teamSpendLogsCall).not.toHaveBeenCalled();
|
||||
expect(networking.tagsSpendLogsCall).not.toHaveBeenCalled();
|
||||
expect(networking.allTagNamesCall).not.toHaveBeenCalled();
|
||||
expect(networking.adminspendByProvider).not.toHaveBeenCalled();
|
||||
expect(networking.adminGlobalActivity).not.toHaveBeenCalled();
|
||||
expect(networking.adminGlobalActivityPerModel).not.toHaveBeenCalled();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe("the admin-only gate", () => {
|
||||
it("proves the flush window is wide enough to catch a leaked request", async () => {
|
||||
renderUsage({ userRole: "Admin" });
|
||||
|
||||
await flushPendingRequests();
|
||||
|
||||
expect(networking.getProxyUISettings).toHaveBeenCalled();
|
||||
expect(networking.adminSpendLogsCall).toHaveBeenCalled();
|
||||
expect(networking.tagsSpendLogsCall).toHaveBeenCalled();
|
||||
expect(networking.adminGlobalActivity).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still lets an admin through, so the notice is a real gate and not a dead branch", async () => {
|
||||
renderUsage({ userRole: "Admin" });
|
||||
|
||||
expect(await screen.findByRole("tab", { name: "All Up" })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Proxy-wide usage is only available to admin users/i)).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(networking.adminSpendLogsCall).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("does not put the session token in the provider spend query", async () => {
|
||||
renderUsage({ userRole: "Admin", token: "session-jwt-value" });
|
||||
|
||||
await waitFor(() => expect(networking.adminspendByProvider).toHaveBeenCalled());
|
||||
const callArgs = networking.adminspendByProvider.mock.calls[0];
|
||||
expect(callArgs).not.toContain("session-jwt-value");
|
||||
expect(callArgs[0]).toBe("sk-test");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,950 +0,0 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
|
||||
import ViewUserSpend from "@/components/view_user_spend";
|
||||
import { ProxySettings } from "@/app/(dashboard)/hooks/proxySettings/useProxySettings";
|
||||
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Meter, MeterIndicator, MeterTrack } from "@/components/shared/Meter";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
NUMERIC_CELL_CLASS,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { AreaChart, BarChart, DonutChart } from "@/components/shared/charts";
|
||||
|
||||
import {
|
||||
adminSpendLogsCall,
|
||||
adminTopKeysCall,
|
||||
adminTopModelsCall,
|
||||
adminTopEndUsersCall,
|
||||
teamSpendLogsCall,
|
||||
tagsSpendLogsCall,
|
||||
allTagNamesCall,
|
||||
adminspendByProvider,
|
||||
adminGlobalActivity,
|
||||
adminGlobalActivityPerModel,
|
||||
getProxyUISettings,
|
||||
} from "@/components/networking";
|
||||
import TopKeyView from "@/components/UsagePage/components/EntityUsage/TopKeyView";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import { hasCapability } from "@/utils/capabilities";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
|
||||
interface UsagePageProps {
|
||||
accessToken: string | null;
|
||||
token: string | null;
|
||||
userRole: string | null;
|
||||
userID: string | null;
|
||||
keys: any[] | null;
|
||||
premiumUser: boolean;
|
||||
}
|
||||
|
||||
interface GlobalActivityData {
|
||||
sum_api_requests: number;
|
||||
sum_total_tokens: number;
|
||||
daily_data: { date: string; api_requests: number; total_tokens: number }[];
|
||||
}
|
||||
|
||||
const EMPTY_GLOBAL_ACTIVITY: GlobalActivityData = { sum_api_requests: 0, sum_total_tokens: 0, daily_data: [] };
|
||||
|
||||
type UsageDateRange = { from?: Date; to?: Date };
|
||||
|
||||
type TeamSpendTotal = { name: string; value: number };
|
||||
|
||||
type TagOption = { value: string; label: string; disabled: boolean };
|
||||
|
||||
const ALL_TAGS = "all-tags";
|
||||
|
||||
const isAdminOrAdminViewer = (role: string | null): boolean => {
|
||||
if (role === null) return false;
|
||||
return role === "Admin" || role === "Admin Viewer";
|
||||
};
|
||||
|
||||
const TeamSpendBarList: React.FC<{ data: TeamSpendTotal[] }> = ({ data }) => {
|
||||
const max = Math.max(0, ...data.map((team) => team.value));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{data.map((team) => (
|
||||
<div key={team.name} className="flex items-center gap-4">
|
||||
<p className="w-1/3 truncate text-sm text-foreground">{team.name}</p>
|
||||
<Meter value={team.value} max={max === 0 ? 1 : max} className="flex-1">
|
||||
<MeterTrack>
|
||||
<MeterIndicator />
|
||||
</MeterTrack>
|
||||
</Meter>
|
||||
<p className="w-24 shrink-0 text-right text-sm tabular-nums text-foreground">
|
||||
{formatNumberWithCommas(team.value, 2)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UsagePage: React.FC<UsagePageProps> = ({ accessToken, token, userRole, userID, keys, premiumUser }) => {
|
||||
const anchor = useComboboxAnchor();
|
||||
const canViewGlobalSpend = hasCapability(userRole, "viewGlobalSpend");
|
||||
const currentDate = new Date();
|
||||
const [keySpendData, setKeySpendData] = useState<any[]>([]);
|
||||
const [topKeys, setTopKeys] = useState<any[]>([]);
|
||||
const [topModels, setTopModels] = useState<any[]>([]);
|
||||
const [topUsers, setTopUsers] = useState<any[]>([]);
|
||||
const [teamSpendData, setTeamSpendData] = useState<any[]>([]);
|
||||
const [topTagsData, setTopTagsData] = useState<any[]>([]);
|
||||
const [allTagNames, setAllTagNames] = useState<string[]>([]);
|
||||
const [uniqueTeamIds, setUniqueTeamIds] = useState<any[]>([]);
|
||||
const [totalSpendPerTeam, setTotalSpendPerTeam] = useState<TeamSpendTotal[]>([]);
|
||||
const [spendByProvider, setSpendByProvider] = useState<any[]>([]);
|
||||
const [globalActivity, setGlobalActivity] = useState<GlobalActivityData>(EMPTY_GLOBAL_ACTIVITY);
|
||||
const [globalActivityPerModel, setGlobalActivityPerModel] = useState<any[]>([]);
|
||||
const [selectedKeyToken, setSelectedKeyToken] = useState<string | null>(null);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([ALL_TAGS]);
|
||||
const [dateValue, setDateValue] = useState<UsageDateRange>({
|
||||
from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
to: new Date(),
|
||||
});
|
||||
const [proxySettings, setProxySettings] = useState<ProxySettings | null>(null);
|
||||
const [totalMonthlySpend, setTotalMonthlySpend] = useState<number>(0);
|
||||
|
||||
const firstDay = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1);
|
||||
const lastDay = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0);
|
||||
|
||||
let startTime = formatDate(firstDay);
|
||||
let endTime = formatDate(lastDay);
|
||||
|
||||
const selectableKeys: { token: string; alias: string }[] = (keys ?? [])
|
||||
.filter((key: any) => key && typeof key["key_alias"] === "string" && key["key_alias"].length > 0)
|
||||
.map((key: any) => ({ token: String(key["token"]), alias: String(key["key_alias"]) }));
|
||||
|
||||
const tagOptions: TagOption[] = [
|
||||
{ value: ALL_TAGS, label: "All Tags", disabled: false },
|
||||
...allTagNames
|
||||
.filter((tag) => tag !== ALL_TAGS)
|
||||
.map((tag) => ({
|
||||
value: tag,
|
||||
label: premiumUser ? tag : `✨ ${tag} (Enterprise only Feature)`,
|
||||
disabled: !premiumUser,
|
||||
})),
|
||||
];
|
||||
|
||||
function valueFormatterNumbers(number: number) {
|
||||
const formatter = new Intl.NumberFormat("en-US", {
|
||||
maximumFractionDigits: 0,
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
});
|
||||
|
||||
return formatter.format(number);
|
||||
}
|
||||
|
||||
const fetchProxySettings = async () => {
|
||||
if (accessToken) {
|
||||
try {
|
||||
const proxy_settings: ProxySettings = await getProxyUISettings(accessToken);
|
||||
return proxy_settings;
|
||||
} catch (error) {
|
||||
console.error("Error fetching proxy settings:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!canViewGlobalSpend) {
|
||||
return;
|
||||
}
|
||||
updateTagSpendData(dateValue.from, dateValue.to);
|
||||
}, [canViewGlobalSpend, dateValue, selectedTags]);
|
||||
|
||||
const updateEndUserData = async (
|
||||
startTime: Date | undefined,
|
||||
endTime: Date | undefined,
|
||||
uiSelectedKey: string | null,
|
||||
) => {
|
||||
if (!startTime || !endTime || !accessToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newTopUserData = await adminTopEndUsersCall(
|
||||
accessToken,
|
||||
uiSelectedKey,
|
||||
startTime.toISOString(),
|
||||
endTime.toISOString(),
|
||||
);
|
||||
setTopUsers(newTopUserData);
|
||||
};
|
||||
|
||||
const updateTagSpendData = async (startTime: Date | undefined, endTime: Date | undefined) => {
|
||||
if (!startTime || !endTime || !accessToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
// we refetch because the state variable can be None when the user refreshes the page
|
||||
const proxy_settings: ProxySettings | undefined = await fetchProxySettings();
|
||||
|
||||
if (proxy_settings?.DISABLE_EXPENSIVE_DB_QUERIES) {
|
||||
return; // Don't run expensive DB queries - return out when SpendLogs has more than 1M rows
|
||||
}
|
||||
|
||||
let top_tags = await tagsSpendLogsCall(
|
||||
accessToken,
|
||||
startTime.toISOString(),
|
||||
endTime.toISOString(),
|
||||
selectedTags.length === 0 ? undefined : selectedTags,
|
||||
);
|
||||
setTopTagsData(top_tags.spend_per_tag);
|
||||
};
|
||||
|
||||
function formatDate(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
let month = date.getMonth() + 1; // JS month index starts from 0
|
||||
let day = date.getDate();
|
||||
|
||||
// Pad with 0 if month or day is less than 10
|
||||
const monthStr = month < 10 ? "0" + month : month;
|
||||
const dayStr = day < 10 ? "0" + day : day;
|
||||
|
||||
return `${year}-${monthStr}-${dayStr}`;
|
||||
}
|
||||
|
||||
const valueFormatter = (number: number) => `$ ${formatNumberWithCommas(number, 2)}`;
|
||||
|
||||
const fetchAndSetData = async (
|
||||
fetchFunction: () => Promise<any>,
|
||||
setStateFunction: React.Dispatch<React.SetStateAction<any>>,
|
||||
errorMessage: string,
|
||||
) => {
|
||||
try {
|
||||
const data = await fetchFunction();
|
||||
setStateFunction(data);
|
||||
} catch (error) {
|
||||
console.error(errorMessage, error);
|
||||
// Optionally, update UI to reflect error state for this specific data
|
||||
}
|
||||
};
|
||||
|
||||
// Update the fillMissingDates function to handle different date formats
|
||||
const fillMissingDates = (data: any[], startDate: Date, endDate: Date, categories: string[]) => {
|
||||
const filledData = [];
|
||||
const currentDate = new Date(startDate);
|
||||
|
||||
// Helper function to standardize date format
|
||||
const standardizeDate = (dateStr: string) => {
|
||||
if (dateStr.includes("-")) {
|
||||
// Already in YYYY-MM-DD format
|
||||
return dateStr;
|
||||
} else {
|
||||
// Convert "Jan 06" format
|
||||
const [month, day] = dateStr.split(" ");
|
||||
const year = new Date().getFullYear();
|
||||
const monthIndex = new Date(`${month} 01 2024`).getMonth();
|
||||
const fullDate = new Date(year, monthIndex, parseInt(day));
|
||||
return fullDate.toISOString().split("T")[0];
|
||||
}
|
||||
};
|
||||
|
||||
// Create a map of existing dates for quick lookup
|
||||
const existingDates = new Map(
|
||||
data.map((item) => {
|
||||
const standardizedDate = standardizeDate(item.date);
|
||||
return [
|
||||
standardizedDate,
|
||||
{
|
||||
...item,
|
||||
date: standardizedDate, // Store standardized date format
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
// Iterate through each date in the range
|
||||
while (currentDate <= endDate) {
|
||||
const dateStr = currentDate.toISOString().split("T")[0];
|
||||
|
||||
if (existingDates.has(dateStr)) {
|
||||
// Use existing data if we have it
|
||||
filledData.push(existingDates.get(dateStr));
|
||||
} else {
|
||||
// Create an entry with zero values
|
||||
const emptyEntry: any = {
|
||||
date: dateStr,
|
||||
api_requests: 0,
|
||||
total_tokens: 0,
|
||||
};
|
||||
|
||||
// Add zero values for each model/team if needed
|
||||
categories.forEach((category) => {
|
||||
if (!emptyEntry[category]) {
|
||||
emptyEntry[category] = 0;
|
||||
}
|
||||
});
|
||||
|
||||
filledData.push(emptyEntry);
|
||||
}
|
||||
|
||||
// Move to next day
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
return filledData;
|
||||
};
|
||||
|
||||
// Update the fetchOverallSpend function
|
||||
const fetchOverallSpend = async () => {
|
||||
if (!accessToken) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await adminSpendLogsCall(accessToken);
|
||||
|
||||
// Get the first and last day of the current month
|
||||
const now = new Date();
|
||||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
// Fill in missing dates
|
||||
const filledData = fillMissingDates(data, firstDay, lastDay, []);
|
||||
|
||||
// Calculate total spend for the month and round to 2 decimal places
|
||||
const monthlyTotal = Number(filledData.reduce((sum, day) => sum + (day.spend || 0), 0).toFixed(2));
|
||||
setTotalMonthlySpend(monthlyTotal);
|
||||
|
||||
setKeySpendData(filledData);
|
||||
} catch (error) {
|
||||
console.error("Error fetching overall spend:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProviderSpend = () =>
|
||||
fetchAndSetData(
|
||||
() => (accessToken ? adminspendByProvider(accessToken, startTime, endTime) : Promise.reject("No access token")),
|
||||
setSpendByProvider,
|
||||
"Error fetching provider spend",
|
||||
);
|
||||
|
||||
const fetchTopKeys = async () => {
|
||||
if (!accessToken) return;
|
||||
await fetchAndSetData(
|
||||
async () => {
|
||||
const top_keys = await adminTopKeysCall(accessToken);
|
||||
return top_keys.map((k: any) => ({
|
||||
key: k["api_key"].substring(0, 10),
|
||||
api_key: k["api_key"],
|
||||
key_alias: k["key_alias"],
|
||||
spend: Number(k["total_spend"].toFixed(2)),
|
||||
}));
|
||||
},
|
||||
setTopKeys,
|
||||
"Error fetching top keys",
|
||||
);
|
||||
};
|
||||
|
||||
const fetchTopModels = async () => {
|
||||
if (!accessToken) return;
|
||||
await fetchAndSetData(
|
||||
async () => {
|
||||
const top_models = await adminTopModelsCall(accessToken);
|
||||
return top_models.map((k: any) => ({
|
||||
key: k["model"],
|
||||
spend: formatNumberWithCommas(k["total_spend"], 2),
|
||||
}));
|
||||
},
|
||||
setTopModels,
|
||||
"Error fetching top models",
|
||||
);
|
||||
};
|
||||
|
||||
// Update the fetchTeamSpend function
|
||||
const fetchTeamSpend = async () => {
|
||||
if (!accessToken) return;
|
||||
await fetchAndSetData(
|
||||
async () => {
|
||||
const teamSpend = await teamSpendLogsCall(accessToken);
|
||||
|
||||
// Get the first and last day of the current month
|
||||
const now = new Date();
|
||||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
// Fill in missing dates with zero values for all teams
|
||||
const filledData = fillMissingDates(teamSpend.daily_spend, firstDay, lastDay, teamSpend.teams);
|
||||
|
||||
setTeamSpendData(filledData);
|
||||
setUniqueTeamIds(teamSpend.teams);
|
||||
return teamSpend.total_spend_per_team.map((tspt: any) => ({
|
||||
name: tspt["team_id"] || "",
|
||||
value: Number(tspt["total_spend"] || 0),
|
||||
}));
|
||||
},
|
||||
setTotalSpendPerTeam,
|
||||
"Error fetching team spend",
|
||||
);
|
||||
};
|
||||
|
||||
const fetchTagNames = () => {
|
||||
if (!accessToken) return;
|
||||
fetchAndSetData(
|
||||
async () => {
|
||||
const all_tag_names = await allTagNamesCall(accessToken);
|
||||
return all_tag_names.tag_names;
|
||||
},
|
||||
setAllTagNames,
|
||||
"Error fetching tag names",
|
||||
);
|
||||
};
|
||||
|
||||
const fetchTopTags = () => {
|
||||
if (!accessToken) return;
|
||||
fetchAndSetData(
|
||||
() => tagsSpendLogsCall(accessToken, dateValue.from?.toISOString(), dateValue.to?.toISOString(), undefined),
|
||||
(data) => setTopTagsData(data.spend_per_tag),
|
||||
"Error fetching top tags",
|
||||
);
|
||||
};
|
||||
|
||||
const fetchTopEndUsers = () => {
|
||||
if (!accessToken) return;
|
||||
fetchAndSetData(
|
||||
() => adminTopEndUsersCall(accessToken, null, undefined, undefined),
|
||||
setTopUsers,
|
||||
"Error fetching top end users",
|
||||
);
|
||||
};
|
||||
|
||||
// Update the fetchGlobalActivity function
|
||||
const fetchGlobalActivity = async () => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
const data = await adminGlobalActivity(accessToken, startTime, endTime);
|
||||
|
||||
// Get the date range from the current month
|
||||
const now = new Date();
|
||||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
// Fill in missing dates for daily_data
|
||||
const filledDailyData = fillMissingDates(data.daily_data || [], firstDay, lastDay, [
|
||||
"api_requests",
|
||||
"total_tokens",
|
||||
]);
|
||||
|
||||
setGlobalActivity({
|
||||
...data,
|
||||
daily_data: filledDailyData,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching global activity:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Update the fetchGlobalActivityPerModel function
|
||||
const fetchGlobalActivityPerModel = async () => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
const data = await adminGlobalActivityPerModel(accessToken, startTime, endTime);
|
||||
|
||||
// Get the date range from the current month
|
||||
const now = new Date();
|
||||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
// Fill in missing dates for each model's daily data
|
||||
const filledModelData = data.map((modelData: any) => ({
|
||||
...modelData,
|
||||
daily_data: fillMissingDates(modelData.daily_data || [], firstDay, lastDay, ["api_requests", "total_tokens"]),
|
||||
}));
|
||||
|
||||
setGlobalActivityPerModel(filledModelData);
|
||||
} catch (error) {
|
||||
console.error("Error fetching global activity per model:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const initlizeUsageData = async () => {
|
||||
if (!canViewGlobalSpend) {
|
||||
return;
|
||||
}
|
||||
if (accessToken && token && userRole && userID) {
|
||||
const proxy_settings: ProxySettings | undefined = await fetchProxySettings();
|
||||
if (proxy_settings) {
|
||||
setProxySettings(proxy_settings); // saved in state so it can be used when rendering UI
|
||||
if (proxy_settings?.DISABLE_EXPENSIVE_DB_QUERIES) {
|
||||
return; // Don't run expensive UI queries - return out of initlizeUsageData at this point
|
||||
}
|
||||
}
|
||||
|
||||
fetchOverallSpend();
|
||||
fetchProviderSpend();
|
||||
fetchTopKeys();
|
||||
fetchTopModels();
|
||||
fetchGlobalActivity();
|
||||
fetchGlobalActivityPerModel();
|
||||
|
||||
if (isAdminOrAdminViewer(userRole)) {
|
||||
fetchTeamSpend();
|
||||
fetchTagNames();
|
||||
fetchTopTags();
|
||||
fetchTopEndUsers();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initlizeUsageData();
|
||||
}, [canViewGlobalSpend, accessToken, token, userRole, userID, startTime, endTime]);
|
||||
|
||||
if (!canViewGlobalSpend) {
|
||||
return (
|
||||
<div className="w-full p-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Usage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Proxy-wide usage is only available to admin users. Your own usage is on the Usage page.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (proxySettings?.DISABLE_EXPENSIVE_DB_QUERIES) {
|
||||
return (
|
||||
<div className="w-full p-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Database Query Limit Reached</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-start gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
SpendLogs in DB has {proxySettings.NUM_SPEND_LOGS_ROWS} rows.
|
||||
<br></br>
|
||||
Please follow our guide to view usage when SpendLogs has more than 1M rows.
|
||||
</p>
|
||||
<Button
|
||||
render={
|
||||
<a href="https://docs.litellm.ai/docs/proxy/cost_tracking" target="_blank" rel="noreferrer">
|
||||
View Usage Guide
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full p-8">
|
||||
<Tabs defaultValue="all-up">
|
||||
<TabsList variant="line" className="mt-2">
|
||||
<TabsTrigger value="all-up">All Up</TabsTrigger>
|
||||
|
||||
{isAdminOrAdminViewer(userRole) && (
|
||||
<>
|
||||
<TabsTrigger value="team-based-usage">Team Based Usage</TabsTrigger>
|
||||
<TabsTrigger value="customer-usage">Customer Usage</TabsTrigger>
|
||||
<TabsTrigger value="tag-based-usage">Tag Based Usage</TabsTrigger>
|
||||
</>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="all-up" keepMounted>
|
||||
<Tabs defaultValue="cost">
|
||||
<TabsList className="mt-1">
|
||||
<TabsTrigger value="cost">Cost</TabsTrigger>
|
||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="cost" keepMounted>
|
||||
<div className="grid h-screen w-full grid-cols-2 gap-2">
|
||||
<div className="col-span-2">
|
||||
<p className="mt-2 mb-2 text-lg text-muted-foreground">
|
||||
Project Spend {new Date().toLocaleString("default", { month: "long" })} 1 -{" "}
|
||||
{new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).getDate()}
|
||||
</p>
|
||||
<ViewUserSpend userSpend={totalMonthlySpend} selectedTeam={null} userMaxBudget={null} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Monthly Spend</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BarChart
|
||||
data={keySpendData}
|
||||
index="date"
|
||||
categories={["spend"]}
|
||||
colors={["cyan"]}
|
||||
valueFormatter={valueFormatter}
|
||||
yAxisWidth={100}
|
||||
tickGap={5}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Top Virtual Keys</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TopKeyView topKeys={topKeys} teams={null} topKeysLimit={5} setTopKeysLimit={() => {}} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Top Models</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BarChart
|
||||
className="mt-4 h-40"
|
||||
data={topModels}
|
||||
index="key"
|
||||
categories={["spend"]}
|
||||
colors={["cyan"]}
|
||||
yAxisWidth={200}
|
||||
layout="vertical"
|
||||
showXAxis={false}
|
||||
showLegend={false}
|
||||
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="col-span-1"></div>
|
||||
<div className="col-span-2">
|
||||
<Card className="mb-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Spend by Provider</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="col-span-1">
|
||||
<DonutChart
|
||||
className="mt-4 h-40"
|
||||
variant="pie"
|
||||
data={spendByProvider}
|
||||
index="provider"
|
||||
category="spend"
|
||||
colors={["cyan"]}
|
||||
valueFormatter={(value) => `$${formatNumberWithCommas(value, 2)}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Provider</TableHead>
|
||||
<TableHead className={NUMERIC_CELL_CLASS}>Spend</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{spendByProvider.map((provider) => (
|
||||
<TableRow key={provider.provider}>
|
||||
<TableCell>{provider.provider}</TableCell>
|
||||
<TableCell className={NUMERIC_CELL_CLASS}>
|
||||
<MoneyCell value={provider.spend} decimals={2} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="activity" keepMounted>
|
||||
<div className="grid h-[75vh] w-full grid-cols-1 gap-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Up</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2">
|
||||
<div>
|
||||
<p className="text-[15px] font-normal text-muted-foreground">
|
||||
API Requests {valueFormatterNumbers(globalActivity.sum_api_requests)}
|
||||
</p>
|
||||
<AreaChart
|
||||
className="h-40"
|
||||
data={globalActivity.daily_data}
|
||||
valueFormatter={valueFormatterNumbers}
|
||||
index="date"
|
||||
colors={["cyan"]}
|
||||
categories={["api_requests"]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[15px] font-normal text-muted-foreground">
|
||||
Tokens {valueFormatterNumbers(globalActivity.sum_total_tokens)}
|
||||
</p>
|
||||
<BarChart
|
||||
className="h-40"
|
||||
data={globalActivity.daily_data}
|
||||
valueFormatter={valueFormatterNumbers}
|
||||
index="date"
|
||||
colors={["cyan"]}
|
||||
categories={["total_tokens"]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{globalActivityPerModel.map((globalActivity, index) => (
|
||||
<Card key={index}>
|
||||
<CardHeader>
|
||||
<CardTitle>{globalActivity.model}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2">
|
||||
<div>
|
||||
<p className="text-[15px] font-normal text-muted-foreground">
|
||||
API Requests {valueFormatterNumbers(globalActivity.sum_api_requests)}
|
||||
</p>
|
||||
<AreaChart
|
||||
className="h-40"
|
||||
data={globalActivity.daily_data}
|
||||
index="date"
|
||||
colors={["cyan"]}
|
||||
categories={["api_requests"]}
|
||||
valueFormatter={valueFormatterNumbers}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[15px] font-normal text-muted-foreground">
|
||||
Tokens {valueFormatterNumbers(globalActivity.sum_total_tokens)}
|
||||
</p>
|
||||
<BarChart
|
||||
className="h-40"
|
||||
data={globalActivity.daily_data}
|
||||
index="date"
|
||||
colors={["cyan"]}
|
||||
categories={["total_tokens"]}
|
||||
valueFormatter={valueFormatterNumbers}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="team-based-usage" keepMounted>
|
||||
<div className="grid h-[75vh] w-full grid-cols-2 gap-2">
|
||||
<div className="col-span-2">
|
||||
<Card className="mb-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Total Spend Per Team</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TeamSpendBarList data={totalSpendPerTeam} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Daily Spend Per Team</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BarChart
|
||||
className="h-72"
|
||||
data={teamSpendData}
|
||||
showLegend={true}
|
||||
index="date"
|
||||
categories={uniqueTeamIds}
|
||||
yAxisWidth={80}
|
||||
stack={true}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="customer-usage" keepMounted>
|
||||
<p className="mb-2 text-[12px] text-muted-foreground italic">
|
||||
Customers of your LLM API calls. Tracked when a `user` param is passed in your LLM calls{" "}
|
||||
<a
|
||||
className="text-primary"
|
||||
href="https://docs.litellm.ai/docs/proxy/users"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
docs here
|
||||
</a>
|
||||
</p>
|
||||
<div className="grid grid-cols-2">
|
||||
<div>
|
||||
<AdvancedDatePicker
|
||||
align="left"
|
||||
value={dateValue}
|
||||
onValueChange={(value) => {
|
||||
setDateValue(value);
|
||||
updateEndUserData(value.from, value.to, null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Select Key</p>
|
||||
<Select
|
||||
value={selectedKeyToken}
|
||||
onValueChange={(value: string | null) => {
|
||||
setSelectedKeyToken(value);
|
||||
updateEndUserData(dateValue.from, dateValue.to, value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="All Keys">
|
||||
{(token: string | null) => selectableKeys.find((key) => key.token === token)?.alias ?? "All Keys"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null}>All Keys</SelectItem>
|
||||
{selectableKeys.map((key) => (
|
||||
<SelectItem key={key.token} value={key.token}>
|
||||
{key.alias}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="mt-4">
|
||||
<CardContent>
|
||||
<div className="max-h-[70vh] min-h-[500px] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Customer</TableHead>
|
||||
<TableHead className={NUMERIC_CELL_CLASS}>Spend</TableHead>
|
||||
<TableHead className={NUMERIC_CELL_CLASS}>Total Events</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{topUsers?.map((user: any, index: number) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{user.end_user}</TableCell>
|
||||
<TableCell className={NUMERIC_CELL_CLASS}>
|
||||
<MoneyCell value={user.total_spend} decimals={2} />
|
||||
</TableCell>
|
||||
<TableCell className={NUMERIC_CELL_CLASS}>{user.total_count}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tag-based-usage" keepMounted>
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="col-span-1">
|
||||
<AdvancedDatePicker
|
||||
align="left"
|
||||
className="mb-4"
|
||||
value={dateValue}
|
||||
onValueChange={(value) => {
|
||||
setDateValue(value);
|
||||
updateTagSpendData(value.from, value.to);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Combobox
|
||||
multiple
|
||||
items={tagOptions}
|
||||
value={tagOptions.filter((option) => selectedTags.includes(option.value))}
|
||||
onValueChange={(options: TagOption[]) => setSelectedTags(options.map((option) => option.value))}
|
||||
isItemEqualToValue={(a: TagOption, b: TagOption) => a.value === b.value}
|
||||
itemToStringLabel={(option: TagOption) => option.label}
|
||||
>
|
||||
<ComboboxChips render={<div ref={anchor} />}>
|
||||
<ComboboxValue>
|
||||
{(options: TagOption[]) =>
|
||||
options.map((option) => (
|
||||
<ComboboxChip key={option.value} aria-label={option.label}>
|
||||
{option.label}
|
||||
</ComboboxChip>
|
||||
))
|
||||
}
|
||||
</ComboboxValue>
|
||||
<ComboboxChipsInput placeholder="Select tags" />
|
||||
</ComboboxChips>
|
||||
<ComboboxContent anchor={anchor}>
|
||||
<ComboboxEmpty>No tags found</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(option: TagOption) => (
|
||||
<ComboboxItem key={option.value} value={option} disabled={option.disabled}>
|
||||
{option.label}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4 grid h-[75vh] w-full grid-cols-2 gap-2">
|
||||
<div className="col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Spend Per Tag</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Get Started by Tracking cost per tag{" "}
|
||||
<a
|
||||
className="text-primary"
|
||||
href="https://docs.litellm.ai/docs/proxy/cost_tracking"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
</p>
|
||||
<BarChart className="h-72" data={topTagsData} index="name" categories={["spend"]} colors={["cyan"]} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsagePage;
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import Usage from "./_components/usage";
|
||||
import { DeprecationBanner } from "@/components/DeprecationBanner";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
export default function OldUsagePage() {
|
||||
const { accessToken, token, userRole, userId: userID, premiumUser } = useAuthorized();
|
||||
return (
|
||||
<>
|
||||
<DeprecationBanner featureName="The old Usage page" />
|
||||
<Usage
|
||||
accessToken={accessToken}
|
||||
token={token}
|
||||
userRole={userRole}
|
||||
userID={userID}
|
||||
keys={null}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -102,10 +102,6 @@ vi.mock("@/components/activity_metrics", () => ({
|
|||
processActivityData: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/view_user_spend", () => ({
|
||||
default: () => <div>View User Spend</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/UsagePage/components/EntityUsage/TopKeyView", () => ({
|
||||
default: () => <div>Top Keys</div>,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -444,30 +444,6 @@ describe("Sidebar (leftnav)", () => {
|
|||
});
|
||||
expect(screen.queryByText("Prompts")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should hide Old Usage from internal users while keeping other Experimental children", async () => {
|
||||
mockUseAuthorized.mockReturnValue(internalAuth);
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByText("Experimental"));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("API Playground")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("Old Usage")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Old Usage to admins", async () => {
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByText("Experimental"));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Old Usage")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Workflow Runs, Memory and Guardrails Monitor render a shell and then 401
|
||||
|
|
@ -704,7 +680,6 @@ describe("Sidebar (leftnav)", () => {
|
|||
expectHref("Models + Endpoints", "/ui/models-and-endpoints");
|
||||
expectHref("Usage", "/ui/usage");
|
||||
expectHref("API Reference", "/ui/api-reference");
|
||||
expectHref("Old Usage", "/ui/old-usage");
|
||||
});
|
||||
|
||||
it("never links a leaf to the legacy ?page= switch", () => {
|
||||
|
|
@ -760,7 +735,6 @@ describe("getBreadcrumb", () => {
|
|||
it("resolves routes whose segment differs from the sidebar page id", () => {
|
||||
expect(getBreadcrumb("/ui/models-and-endpoints")).toEqual({ section: "AI Gateway", title: "Models + Endpoints" });
|
||||
expect(getBreadcrumb("/ui/usage")).toEqual({ section: "Observability", title: "Usage" });
|
||||
expect(getBreadcrumb("/ui/old-usage")).toEqual({ section: "Developer Tools", title: "Old Usage" });
|
||||
});
|
||||
|
||||
it("titles the dashboard root as Home", () => {
|
||||
|
|
|
|||
|
|
@ -414,14 +414,6 @@ const menuGroups: MenuGroup[] = [
|
|||
icon: <Tags {...ICON} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "4",
|
||||
page: "usage",
|
||||
route: "old-usage",
|
||||
label: "Old Usage",
|
||||
icon: <BarChart3 {...ICON} />,
|
||||
roles: rolesWithCapability("viewGlobalSpend"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ export const pageDescriptions: Record<string, string> = {
|
|||
"tag-management": "Organize resources with tags",
|
||||
prompts: "Manage and version prompt templates",
|
||||
skills: "Browse and manage Claude Code skills",
|
||||
usage: "View legacy usage dashboard",
|
||||
"router-settings": "Configure routing and load balancing settings",
|
||||
telemetry: "Choose which anonymous usage telemetry this proxy collects and see the last report",
|
||||
"logging-and-alerts": "Set up logging and alert configurations",
|
||||
|
|
|
|||
|
|
@ -1,144 +0,0 @@
|
|||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { modelAvailableCall } from "./networking";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
// Define the props type
|
||||
interface ViewUserSpendProps {
|
||||
userSpend: number | null;
|
||||
userMaxBudget: number | null;
|
||||
selectedTeam: any | null;
|
||||
}
|
||||
const ViewUserSpend: React.FC<ViewUserSpendProps> = ({ userSpend, userMaxBudget, selectedTeam }) => {
|
||||
const { accessToken, userRole, userId: userID } = useAuthorized();
|
||||
let [spend, setSpend] = useState(userSpend !== null ? userSpend : 0.0);
|
||||
const [maxBudget, setMaxBudget] = useState(
|
||||
selectedTeam ? Number(formatNumberWithCommas(selectedTeam.max_budget, 4)) : null,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (selectedTeam) {
|
||||
if (selectedTeam.team_alias === "Default Team") {
|
||||
setMaxBudget(userMaxBudget);
|
||||
} else {
|
||||
let setMaxBudgetFlag = false;
|
||||
if (selectedTeam.team_memberships) {
|
||||
/**
|
||||
* What 'team_memberships' looks like:
|
||||
* "team_memberships": [
|
||||
* {
|
||||
* "user_id": "2c315de3-e7ce-4269-b73e-b039a06187b1",
|
||||
* "team_id": "test-team_515e6f42-ded2-4f0d-8919-0a1f43c5a45f",
|
||||
* "budget_id": "0880769f-716a-4149-ab19-7f7651ad4db5",
|
||||
* "litellm_budget_table": {
|
||||
"soft_budget": null,
|
||||
"max_budget": 20.0,
|
||||
"max_parallel_requests": null,
|
||||
"tpm_limit": null,
|
||||
"rpm_limit": null,
|
||||
"model_max_budget": null,
|
||||
"budget_duration": null
|
||||
}
|
||||
*/
|
||||
for (const member of selectedTeam.team_memberships) {
|
||||
if (
|
||||
member.user_id === userID &&
|
||||
"max_budget" in member.litellm_budget_table &&
|
||||
member.litellm_budget_table.max_budget !== null
|
||||
) {
|
||||
setMaxBudget(member.litellm_budget_table.max_budget);
|
||||
setMaxBudgetFlag = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!setMaxBudgetFlag) {
|
||||
setMaxBudget(selectedTeam.max_budget);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setMaxBudget(userMaxBudget);
|
||||
}
|
||||
}, [selectedTeam, userMaxBudget]);
|
||||
const [userModels, setUserModels] = useState([]);
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!accessToken || !userID || !userRole) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
const fetchUserModels = async () => {
|
||||
try {
|
||||
if (userID === null || userRole === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (accessToken !== null) {
|
||||
const model_available = await modelAvailableCall(accessToken, userID, userRole);
|
||||
let available_model_names = model_available["data"].map((element: { id: string }) => element.id);
|
||||
setUserModels(available_model_names);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching user models:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserModels();
|
||||
fetchData();
|
||||
}, [userRole, accessToken, userID]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userSpend !== null) {
|
||||
setSpend(userSpend);
|
||||
}
|
||||
}, [userSpend]);
|
||||
|
||||
// logic to decide what models to display
|
||||
let modelsToDisplay = [];
|
||||
if (selectedTeam && selectedTeam.models) {
|
||||
modelsToDisplay = selectedTeam.models;
|
||||
}
|
||||
|
||||
// check if "all-proxy-models" is in modelsToDisplay
|
||||
if (modelsToDisplay && modelsToDisplay.includes("all-proxy-models")) {
|
||||
modelsToDisplay = userModels;
|
||||
} else if (modelsToDisplay && modelsToDisplay.includes("all-team-models")) {
|
||||
modelsToDisplay = selectedTeam.models;
|
||||
} else if (modelsToDisplay && modelsToDisplay.length === 0) {
|
||||
modelsToDisplay = userModels;
|
||||
}
|
||||
|
||||
const displayMaxBudget = maxBudget !== null ? `$${formatNumberWithCommas(Number(maxBudget), 4)} limit` : "No limit";
|
||||
|
||||
const roundedSpend = spend !== undefined ? formatNumberWithCommas(spend, 4) : null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="flex justify-between gap-x-6">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Spend</p>
|
||||
<p className="text-2xl font-semibold text-foreground">${roundedSpend}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Max Budget</p>
|
||||
<p className="text-2xl font-semibold text-foreground">{displayMaxBudget}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className="ml-auto">
|
||||
<Accordion>
|
||||
<AccordionHeader><Text>Team Models</Text></AccordionHeader>
|
||||
<AccordionBody className="absolute right-0 z-floating bg-card p-2 shadow-lg max-w-xs">
|
||||
<List>
|
||||
{modelsToDisplay.map((model: string) => (
|
||||
<ListItem key={model}>
|
||||
<Text>{model}</Text>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewUserSpend;
|
||||
|
|
@ -127,7 +127,6 @@ vi.mock("@/components/new_usage", () => ({ default: stub("new-usage") }));
|
|||
vi.mock("@/components/api_ref", () => ({ default: stub("api-ref") }));
|
||||
vi.mock("@/components/chat_ui/ChatUI", () => ({ default: stub("chat-ui") }));
|
||||
vi.mock("@/components/leftnav", () => ({ default: stub("sidebar") }));
|
||||
vi.mock("@/app/(dashboard)/old-usage/_components/usage", () => ({ default: stub("usage") }));
|
||||
vi.mock("@/components/cache_dashboard", () => ({ default: stub("cache-dashboard") }));
|
||||
vi.mock("@/app/(dashboard)/guardrails/_components", () => ({ default: stub("guardrails") }));
|
||||
vi.mock("@/components/prompts", () => ({ default: stub("prompts") }));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue