diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx index 693ac20a360..2b7ea26ea49 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx @@ -53,6 +53,21 @@ describe("KeyActivityPanel", () => { expect(screen.getByText("Showing 1 of 2 keys")).toBeInTheDocument(); }); + it("narrows the rendered keys with a glob", () => { + render(); + fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "bob-*" } }); + expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-bob"); + expect(screen.getByTestId("rendered-keys")).not.toHaveTextContent("hash-alice"); + expect(screen.getByText("Showing 1 of 2 keys")).toBeInTheDocument(); + }); + + it("shows an invalid regular expression state instead of the keys", () => { + render(); + fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "/[/" } }); + expect(screen.getByText("Invalid regular expression: /[/")).toBeInTheDocument(); + expect(screen.queryByTestId("rendered-keys")).not.toBeInTheDocument(); + }); + it("shows an empty state instead of zeroed metrics when nothing matches", () => { render(); fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "carol" } }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx index 8287a04d0c7..4cc7cf26287 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx @@ -4,7 +4,7 @@ import React, { useMemo, useState } from "react"; import { ActivityMetrics } from "@/components/activity_metrics"; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; -import { filterKeyActivity } from "../keyActivityFilter"; +import { filterKeyActivity, parseKeyQuery } from "../keyActivityFilter"; import type { ModelActivityData } from "../types"; interface KeyActivityPanelProps { @@ -14,10 +14,12 @@ interface KeyActivityPanelProps { const KeyActivityPanel: React.FC = ({ keyMetrics, hidePromptCachingMetrics = false }) => { const [query, setQuery] = useState(""); + const parsed = useMemo(() => parseKeyQuery(query), [query]); const filtered = useMemo(() => filterKeyActivity(keyMetrics, query), [keyMetrics, query]); const totalKeys = Object.keys(keyMetrics).length; const shownKeys = Object.keys(filtered).length; - const isFiltering = query.trim() !== ""; + const isFiltering = parsed.kind !== "all"; + const hasNoMatches = isFiltering && totalKeys > 0 && shownKeys === 0; return (
@@ -28,7 +30,7 @@ const KeyActivityPanel: React.FC = ({ keyMetrics, hidePro setQuery(e.target.value)} /> @@ -44,11 +46,17 @@ const KeyActivityPanel: React.FC = ({ keyMetrics, hidePro Showing {shownKeys.toLocaleString()} of {totalKeys.toLocaleString()} keys
- {isFiltering && totalKeys > 0 && shownKeys === 0 ? ( + {parsed.kind === "invalid" && ( +

+ Invalid regular expression: {parsed.source} +

+ )} + {parsed.kind !== "invalid" && hasNoMatches && (

No keys match "{query.trim()}" in this date range

- ) : ( + )} + {parsed.kind !== "invalid" && !hasNoMatches && ( )} diff --git a/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.test.ts b/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.test.ts index ce181f6b0c6..f32e3a92c0f 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.test.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { filterKeyActivity, keyActivityMatches } from "./keyActivityFilter"; +import { filterKeyActivity, keyActivityMatches, parseKeyQuery } from "./keyActivityFilter"; import type { KeyMetadata, ModelActivityData } from "./types"; function activity(label: string, key_metadata?: KeyMetadata): ModelActivityData { @@ -75,6 +75,56 @@ describe("keyActivityMatches", () => { }); }); +describe("parseKeyQuery and keyActivityMatches", () => { + it("matches anchored, case-insensitive globs", () => { + expect(keyActivityMatches("hash-alice", alice, "alice-*")).toBe(true); + expect(keyActivityMatches("hash-alice", alice, "batch-*")).toBe(false); + expect(keyActivityMatches("hash-alice", activity("production batch-key", aliceMeta), "batch-*")).toBe(false); + expect(keyActivityMatches("hash-alice", alice, "*-batch")).toBe(true); + expect(keyActivityMatches("hash-alice", alice, "ALICE-*")).toBe(true); + }); + + it("treats regex punctuation literally in globs", () => { + expect(keyActivityMatches("hash-alice", alice, "alice.batch*")).toBe(false); + }); + + it("matches the key hash with a glob", () => { + expect(keyActivityMatches("deadbeef", orphan, "dead*")).toBe(true); + }); + + it("matches regular expressions with standard case sensitivity", () => { + expect(keyActivityMatches("hash-bob", bob, "/^user-bob-\\d+$/")).toBe(true); + expect(keyActivityMatches("hash-alice", alice, "/^user-bob-\\d+$/")).toBe(false); + expect(keyActivityMatches("hash-alice", alice, "/^ALICE/")).toBe(false); + expect(keyActivityMatches("hash-alice", alice, "/^ALICE/i")).toBe(true); + }); + + it("marks malformed regular expressions invalid and matches nothing", () => { + expect(parseKeyQuery("/[/")).toEqual({ kind: "invalid", source: "/[/" }); + expect(keyActivityMatches("hash-alice", alice, "/[/")).toBe(false); + }); + + it("strips stateful flags from a regular expression reused for multiple matches", () => { + const query = parseKeyQuery("/^alice/g"); + expect(query.kind).toBe("pattern"); + if (query.kind !== "pattern") return; + + expect(query.regex.flags).not.toContain("g"); + expect(query.regex.test("alice-batch")).toBe(true); + expect(query.regex.test("alice-batch")).toBe(true); + expect( + Object.keys( + filterKeyActivity({ "hash-alice": alice, "hash-alice-2": activity("alice-secondary", aliceMeta) }, "/^alice/g"), + ), + ).toEqual(["hash-alice", "hash-alice-2"]); + }); + + it("keeps a lone slash and a slash within text as substring queries", () => { + expect(parseKeyQuery("/")).toEqual({ kind: "substring", needle: "/" }); + expect(parseKeyQuery("a/b")).toEqual({ kind: "substring", needle: "a/b" }); + }); +}); + describe("filterKeyActivity", () => { it("returns the same object when the query is blank", () => { expect(filterKeyActivity(keyMetrics, "")).toBe(keyMetrics); @@ -88,4 +138,8 @@ describe("filterKeyActivity", () => { it("returns an empty record when nothing matches", () => { expect(filterKeyActivity(keyMetrics, "nobody")).toEqual({}); }); + + it("keeps the hashes matching a glob", () => { + expect(filterKeyActivity(keyMetrics, "user-bob-*")).toEqual({ "hash-bob": bob }); + }); }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.ts b/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.ts index 1e9a654fb2e..a4b79708994 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.ts @@ -1,20 +1,67 @@ import type { ModelActivityData } from "./types"; -export function keyActivityMatches(apiKey: string, data: ModelActivityData, query: string): boolean { - const needle = query.trim().toLowerCase(); - if (needle === "") return true; +export type KeyQuery = + | { readonly kind: "all" } + | { readonly kind: "substring"; readonly needle: string } + | { readonly kind: "pattern"; readonly regex: RegExp } + | { readonly kind: "invalid"; readonly source: string }; + +export function parseKeyQuery(query: string): KeyQuery { + const trimmedQuery = query.trim(); + if (trimmedQuery === "") return { kind: "all" }; + + const regexLiteral = new RegExp("^/(.+)/([a-z]*)$", "s").exec(trimmedQuery); + if (regexLiteral !== null) { + const [, body, flags] = regexLiteral; + try { + return { kind: "pattern", regex: new RegExp(body, flags.replace(/[gy]/g, "")) }; + } catch { + return { kind: "invalid", source: trimmedQuery }; + } + } + + if (trimmedQuery.includes("*")) { + const escapedGlob = trimmedQuery.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); + return { kind: "pattern", regex: new RegExp(`^${escapedGlob.replace(/\*/g, ".*")}$`, "i") }; + } + + return { kind: "substring", needle: trimmedQuery.toLowerCase() }; +} + +function keyActivityFields(apiKey: string, data: ModelActivityData): readonly (string | null | undefined)[] { const meta = data.key_metadata; - return [apiKey, data.label, meta?.key_alias, meta?.user_id, meta?.user_email].some( - (field) => field?.toLowerCase().includes(needle) ?? false, - ); + return [apiKey, data.label, meta?.key_alias, meta?.user_id, meta?.user_email]; +} + +function matchesQuery(fields: readonly (string | null | undefined)[], query: KeyQuery): boolean { + switch (query.kind) { + case "all": + return true; + case "substring": + return fields.some((field) => field?.toLowerCase().includes(query.needle) ?? false); + case "pattern": + return fields.some((field) => typeof field === "string" && query.regex.test(field)); + case "invalid": + return false; + default: { + const unreachableQuery: never = query; + return unreachableQuery; + } + } +} + +export function keyActivityMatches(apiKey: string, data: ModelActivityData, query: string): boolean { + return matchesQuery(keyActivityFields(apiKey, data), parseKeyQuery(query)); } export function filterKeyActivity( keyMetrics: Record, query: string, ): Record { - if (query.trim() === "") return keyMetrics; + const parsedQuery = parseKeyQuery(query); + if (parsedQuery.kind === "all") return keyMetrics; + return Object.fromEntries( - Object.entries(keyMetrics).filter(([apiKey, data]) => keyActivityMatches(apiKey, data, query)), + Object.entries(keyMetrics).filter(([apiKey, data]) => matchesQuery(keyActivityFields(apiKey, data), parsedQuery)), ); }