mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
Merge d6bc070b15 into 431ecd8920
This commit is contained in:
commit
36e1ad37ca
4 changed files with 138 additions and 14 deletions
|
|
@ -53,6 +53,21 @@ describe("KeyActivityPanel", () => {
|
|||
expect(screen.getByText("Showing 1 of 2 keys")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("narrows the rendered keys with a glob", () => {
|
||||
render(<KeyActivityPanel keyMetrics={keyMetrics} />);
|
||||
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(<KeyActivityPanel keyMetrics={keyMetrics} />);
|
||||
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(<KeyActivityPanel keyMetrics={keyMetrics} />);
|
||||
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "carol" } });
|
||||
|
|
|
|||
|
|
@ -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<KeyActivityPanelProps> = ({ 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 (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -28,7 +30,7 @@ const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({ keyMetrics, hidePro
|
|||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
aria-label="Search keys"
|
||||
placeholder="Search by key alias, key hash, user ID, or email"
|
||||
placeholder="Search by key alias, hash, user ID, or email. Supports * and /regex/"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
|
|
@ -44,11 +46,17 @@ const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({ keyMetrics, hidePro
|
|||
Showing {shownKeys.toLocaleString()} of {totalKeys.toLocaleString()} keys
|
||||
</span>
|
||||
</div>
|
||||
{isFiltering && totalKeys > 0 && shownKeys === 0 ? (
|
||||
{parsed.kind === "invalid" && (
|
||||
<p className="rounded-lg border p-6 text-center text-sm text-muted-foreground">
|
||||
Invalid regular expression: {parsed.source}
|
||||
</p>
|
||||
)}
|
||||
{parsed.kind !== "invalid" && hasNoMatches && (
|
||||
<p className="rounded-lg border p-6 text-center text-sm text-muted-foreground">
|
||||
No keys match "{query.trim()}" in this date range
|
||||
</p>
|
||||
) : (
|
||||
)}
|
||||
{parsed.kind !== "invalid" && !hasNoMatches && (
|
||||
<ActivityMetrics modelMetrics={filtered} hidePromptCachingMetrics={hidePromptCachingMetrics} />
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, ModelActivityData>,
|
||||
query: string,
|
||||
): Record<string, ModelActivityData> {
|
||||
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)),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue