diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx
index 41873ca554b..d0c5f4c49b4 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx
@@ -4,14 +4,31 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin";
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
-import { act, fireEvent, screen, waitFor } from "@testing-library/react";
+import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "@/../tests/test-utils";
+import { processActivityData } from "@/components/activity_metrics";
import type { Organization } from "@/components/networking";
+import type { ModelActivityData } from "@/components/UsagePage/types";
import * as networking from "@/components/networking";
import UsagePage from "./UsagePageView";
+const modelActivity = (label: string): ModelActivityData => ({
+ label,
+ total_requests: 1,
+ total_successful_requests: 1,
+ total_failed_requests: 0,
+ total_cache_read_input_tokens: 0,
+ total_cache_creation_input_tokens: 0,
+ total_tokens: 10,
+ prompt_tokens: 5,
+ completion_tokens: 5,
+ total_spend: 0.01,
+ top_models: [],
+ daily_data: [],
+});
+
// Polyfill ResizeObserver for test environment
beforeAll(() => {
if (typeof window !== "undefined" && !window.ResizeObserver) {
@@ -36,10 +53,14 @@ vi.mock("@/components/networking", () => ({
// Mock child components to simplify testing
vi.mock("@/components/activity_metrics", () => ({
- ActivityMetrics: ({ modelMetrics }: { modelMetrics?: { __source?: string } }) => (
-
{`activity-source:${modelMetrics?.__source ?? "none"}`}
+ ActivityMetrics: ({ modelMetrics }: { modelMetrics?: Record }) => (
+
+ {Object.entries(modelMetrics ?? {}).map(([model, metrics]) => (
+ {metrics.label}
+ ))}
+
),
- processActivityData: (_data: unknown, key: string) => ({ __source: key }),
+ processActivityData: vi.fn(),
}));
vi.mock("@/components/view_user_spend", () => ({
@@ -366,6 +387,10 @@ describe("UsagePage", () => {
};
beforeEach(() => {
+ vi.mocked(processActivityData).mockReset();
+ vi.mocked(processActivityData).mockImplementation((_data, key) => ({
+ [key]: modelActivity(`activity-source:${key}`),
+ }));
mockUseAuthorized.mockReturnValue({
isLoading: false,
isAuthorized: true,
@@ -1239,6 +1264,50 @@ describe("UsagePage", () => {
});
expect(screen.queryByText("activity-source:model_groups")).not.toBeInTheDocument();
});
+
+ it("filters Model Activity by model name and shows an empty state when there are no matches", async () => {
+ vi.mocked(processActivityData).mockReturnValue({
+ "openai/gpt-4o": modelActivity("GPT-4o"),
+ "anthropic/claude-3": modelActivity("Claude 3 Sonnet"),
+ });
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
+ });
+ fireEvent.click(screen.getByRole("tab", { name: "Model Activity" }));
+
+ const modelActivityTab = screen.getByRole("tabpanel", { name: "Model Activity" });
+ const searchInput = within(modelActivityTab).getByRole("textbox", { name: "Search models" });
+ fireEvent.change(searchInput, { target: { value: "GPT-4o" } });
+
+ expect(within(modelActivityTab).getByText("GPT-4o")).toBeInTheDocument();
+ expect(within(modelActivityTab).queryByText("Claude 3 Sonnet")).not.toBeInTheDocument();
+
+ fireEvent.change(searchInput, { target: { value: "missing model" } });
+
+ expect(
+ within(modelActivityTab).getByText('No models match "missing model" in this date range'),
+ ).toBeInTheDocument();
+ });
+
+ it("does not show the no-match state when model data is empty", async () => {
+ vi.mocked(processActivityData).mockReturnValue({});
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
+ });
+ fireEvent.click(screen.getByRole("tab", { name: "Model Activity" }));
+
+ const modelActivityTab = screen.getByRole("tabpanel", { name: "Model Activity" });
+ const searchInput = within(modelActivityTab).getByRole("textbox", { name: "Search models" });
+ fireEvent.change(searchInput, { target: { value: "missing model" } });
+
+ expect(
+ within(modelActivityTab).queryByText('No models match "missing model" in this date range'),
+ ).not.toBeInTheDocument();
+ });
});
describe("customer usage banner", () => {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx
index 6f18b2fe1d2..4fc43fdabd5 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx
@@ -6,7 +6,7 @@
* Works at 1m+ spend logs, by querying an aggregate table instead.
*/
-import { ChevronDown, ChevronRight, Download, Info, Sparkles, X } from "lucide-react";
+import { ChevronDown, ChevronRight, Download, Info, Search, Sparkles, X } from "lucide-react";
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
import React, { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -14,6 +14,7 @@ import { BarChart } from "@/components/shared/charts";
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { Button } from "@/components/ui/button";
import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Skeleton } from "@/components/ui/skeleton";
@@ -31,6 +32,7 @@ import CloudZeroExportModal from "@/components/cloudzero_export_modal";
import UserDropdown from "@/components/common_components/UserDropdown";
import EntityUsageExportModal from "@/components/EntityUsageExport";
import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel";
+import { filterModelActivity } from "@/components/UsagePage/modelActivityFilter";
import { Team } from "@/components/key_team_helpers/key_list";
import { gatewayDailyActivityCall, Organization, tagListCall } from "@/components/networking";
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
@@ -108,6 +110,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => {
// For non-admins: always set to their own user ID
const [selectedUserId, setSelectedUserId] = useState(isAdmin ? null : userID || null);
const [modelViewType, setModelViewType] = useState("groups");
+ const [modelQuery, setModelQuery] = useState("");
const [isCloudZeroModalOpen, setIsCloudZeroModalOpen] = useState(false);
const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false);
const [isAiChatOpen, setIsAiChatOpen] = useState(false);
@@ -408,6 +411,8 @@ const UsagePage: React.FC = ({ teams, organizations }) => {
() => processActivityData(userSpendData, modelViewType === "groups" ? "model_groups" : "models", teams),
[userSpendData, modelViewType, teams],
);
+ const filteredModelMetrics = useMemo(() => filterModelActivity(modelMetrics, modelQuery), [modelMetrics, modelQuery]);
+ const trimmedModelQuery = modelQuery.trim();
const mcpServerMetrics = useMemo(
() => processActivityData(userSpendData, "mcp_servers", teams),
[userSpendData, teams],
@@ -865,13 +870,43 @@ const UsagePage: React.FC = ({ teams, organizations }) => {
{/* Activity Panel */}
-
+
+
+
+
+
+ setModelQuery(event.target.value)}
+ />
+ {trimmedModelQuery !== "" && (
+
+ setModelQuery("")}
+ >
+
+
+
+ )}
+
-
+ {trimmedModelQuery !== "" &&
+ Object.keys(modelMetrics).length > 0 &&
+ Object.keys(filteredModelMetrics).length === 0 ? (
+
+ No models match "{trimmedModelQuery}" in this date range
+
+ ) : (
+
+ )}
{
expect(screen.queryByRole("button", { name: /old-scope-key/ })).not.toBeInTheDocument();
expect(secondScopeFetch).toHaveBeenCalledWith(0, 50);
});
+
+ it("renders key search before the summary metrics", async () => {
+ render(
+ ,
+ );
+
+ const searchInput = await screen.findByRole("textbox", { name: "Search keys" });
+ const summaryHeading = await screen.findByRole("heading", { name: "Overall Usage" });
+
+ expect(searchInput.compareDocumentPosition(summaryHeading) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ );
+ });
});
diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx
index ad28a398c83..77e7a79dc6b 100644
--- a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx
+++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx
@@ -384,15 +384,6 @@ const KeyActivityPanel: React.FC = ({
return (
- {summaryLoading ? (
-
- ) : (
-
- )}
@@ -418,6 +409,15 @@ const KeyActivityPanel: React.FC = ({
)}
+ {summaryLoading ? (
+
+ ) : (
+
+ )}
{noMatches ? emptyFilterBody :
{listBody}
}
{showSearchErrorNote && (
Could not search all keys. {searchRetryButton}
diff --git a/ui/litellm-dashboard/src/components/UsagePage/modelActivityFilter.test.ts b/ui/litellm-dashboard/src/components/UsagePage/modelActivityFilter.test.ts
new file mode 100644
index 00000000000..892e3910f65
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/UsagePage/modelActivityFilter.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vitest";
+
+import { filterModelActivity } from "./modelActivityFilter";
+import type { ModelActivityData } from "./types";
+
+const activity = (label: string): ModelActivityData => ({
+ label,
+ total_requests: 1,
+ total_successful_requests: 1,
+ total_failed_requests: 0,
+ total_cache_read_input_tokens: 0,
+ total_cache_creation_input_tokens: 0,
+ total_tokens: 10,
+ prompt_tokens: 5,
+ completion_tokens: 5,
+ total_spend: 0.01,
+ top_models: [],
+ daily_data: [],
+});
+
+const modelMetrics: Record
= {
+ "openai/gpt-4o": activity("GPT-4o"),
+ "anthropic/claude-3": activity("Claude 3 Sonnet"),
+};
+
+describe("filterModelActivity", () => {
+ it("matches by model key", () => {
+ expect(Object.keys(filterModelActivity(modelMetrics, "openai"))).toEqual(["openai/gpt-4o"]);
+ });
+
+ it("matches by model label", () => {
+ expect(Object.keys(filterModelActivity(modelMetrics, "Sonnet"))).toEqual(["anthropic/claude-3"]);
+ });
+
+ it("matches case-insensitively", () => {
+ expect(Object.keys(filterModelActivity(modelMetrics, "GPT-4O"))).toEqual(["openai/gpt-4o"]);
+ });
+
+ it("returns all models unchanged for a whitespace-only query", () => {
+ expect(filterModelActivity(modelMetrics, " ")).toBe(modelMetrics);
+ });
+
+ it("returns an empty record when nothing matches", () => {
+ expect(filterModelActivity(modelMetrics, "unknown")).toEqual({});
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/UsagePage/modelActivityFilter.ts b/ui/litellm-dashboard/src/components/UsagePage/modelActivityFilter.ts
new file mode 100644
index 00000000000..ee85a5fa894
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/UsagePage/modelActivityFilter.ts
@@ -0,0 +1,14 @@
+import type { ModelActivityData } from "./types";
+
+export function filterModelActivity(
+ modelMetrics: Record,
+ query: string,
+): Record {
+ const needle = query.trim().toLowerCase();
+ if (needle === "") return modelMetrics;
+ return Object.fromEntries(
+ Object.entries(modelMetrics).filter(
+ ([model, data]) => model.toLowerCase().includes(needle) || data.label.toLowerCase().includes(needle),
+ ),
+ );
+}