= ({ 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)),
);
}