diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index d55af56e248..850b0a4b0f8 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -2607,6 +2607,10 @@ async def ui_view_spend_logs( ), model_group: str | None = fastapi.Query(default=None, description="Filter logs by model group"), key_alias: str | None = fastapi.Query(default=None, description="Filter logs by key alias"), + exclude_key_alias: str | None = fastapi.Query( + default=None, + description="Exclude logs whose key alias exactly matches this value; logs without an alias remain included", + ), end_user: str | None = fastapi.Query(default=None, description="Filter logs by end user"), error_code: str | None = fastapi.Query(default=None, description="Filter logs by error code (e.g., '404', '500')"), error_message: str | None = fastapi.Query( @@ -2963,6 +2967,10 @@ async def ui_view_spend_logs( sql_conditions.append(f"metadata->>'user_api_key_alias' LIKE ${p}") sql_params.append(f"%{key_alias}%") p += 1 + if exclude_key_alias: + sql_conditions.append(f"metadata->>'user_api_key_alias' IS DISTINCT FROM ${p}") + sql_params.append(exclude_key_alias) + p += 1 # rebind-ok: advances the existing shared SQL placeholder counter if error_code is not None: sql_conditions.append(f"metadata->'error_information'->>'error_code' = ${p}") sql_params.append(error_code) diff --git a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py index e119acd5ffc..92e8dc7be41 100644 --- a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py @@ -129,6 +129,7 @@ def _reconstruct_ui_where_from_sql(sql_query, params): gte = re.search(r'"startTime" >= \(\$(\d+)', cond) lte = re.search(r'"startTime" <= \(\$(\d+)', cond) alias = re.search(r"user_api_key_alias' LIKE \$(\d+)", cond) + excluded_alias = re.search(r"user_api_key_alias' IS DISTINCT FROM \$(\d+)", cond) code = re.search(r"error_code' = \$(\d+)", cond) msg = re.search(r"error_message' LIKE \$(\d+)", cond) credential = re.fullmatch(r"metadata->>'used_client_oauth_token' = \$(\d+)", cond) @@ -175,6 +176,8 @@ def _reconstruct_ui_where_from_sql(sql_query, params): "string_contains": str(params[int(alias.group(1)) - 1]).strip("%"), } ) + elif excluded_alias: + where["exclude_key_alias"] = params[int(excluded_alias.group(1)) - 1] elif code: metadata_conds.append( { @@ -3708,6 +3711,71 @@ async def test_ui_view_spend_logs_with_model_group(client, monkeypatch): app.dependency_overrides.pop(ps.user_api_key_auth, None) +@pytest.mark.asyncio +@pytest.mark.parametrize("route", ["/spend/logs/ui", "/spend/logs/v2"]) +async def test_ui_view_spend_logs_excludes_exact_key_alias_before_pagination(client, monkeypatch, route): + aliases = ["noisy", "noisy-other", None, "quiet", "noisy"] + logs = [ + { + "id": f"log{i}", + "request_id": f"req{i}", + "api_key": "sk-test-key", + "spend": 0.05, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + "metadata": json.dumps({"user_api_key_alias": alias}) if alias is not None else "{}", + } + for i, alias in enumerate(aliases) + ] + queries = [] + + def filter_by_alias(where): + if where.get("exclude_key_alias") != "noisy": + return logs + return [log for log, alias in zip(logs, aliases) if alias != "noisy"] + + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma( + logs, filter_by_alias, query_observer=lambda sql, params: queries.append((sql, params)) + ), + ) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user" + ) + try: + start_date, end_date = _default_date_range() + responses = [ + client.get( + route, + params={ + "exclude_key_alias": "noisy", + "start_date": start_date, + "end_date": end_date, + "page": page, + "page_size": 2, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + for page in (1, 2) + ] + assert [response.status_code for response in responses] == [200, 200] + assert [response.json()["total"] for response in responses] == [3, 3] + assert [response.json()["total_pages"] for response in responses] == [2, 2] + assert [[row["request_id"] for row in response.json()["data"]] for response in responses] == [ + ["req1", "req2"], + ["req3"], + ] + for sql, params in queries: + if "WHERE" not in sql: + continue + alias_placeholder = re.search(r"user_api_key_alias' IS DISTINCT FROM \$(\d+)", sql) + assert alias_placeholder is not None + assert params[int(alias_placeholder.group(1)) - 1] == "noisy" + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + @pytest.mark.asyncio async def test_ui_view_spend_logs_with_key_hash(client, monkeypatch): mock_spend_logs = [ @@ -7376,6 +7444,77 @@ async def test_ui_view_spend_logs_group_by_session_first_page(client, monkeypatc app.dependency_overrides.pop(ps.user_api_key_auth, None) +@pytest.mark.asyncio +async def test_ui_view_spend_logs_grouped_exclusion_reaches_page_count_and_representatives(client, monkeypatch): + page_rows = [ + _session_page_row("sess-1", "2026-08-29 10:00:00"), + _session_page_row("sess-2", "2026-08-29 09:00:00"), + _session_page_row("sess-3", "2026-08-29 08:00:00"), + ] + session_aliases = {"sess-1": "noisy", "sess-2": "quiet", "sess-3": "quiet"} + representatives = [] + for request_id, session_id in (("req-1", "sess-1"), ("req-2", "sess-2"), ("req-3", "sess-3")): + representative = _session_representative_row(request_id, session_id) + representative["metadata"] = {"user_api_key_alias": session_aliases[session_id]} + representatives.append(representative) + + async def mock_query_raw(sql_query, *params): + excluded_alias_match = re.search(r"user_api_key_alias' IS DISTINCT FROM \$(\d+)", sql_query) + excluded_alias = params[int(excluded_alias_match.group(1)) - 1] if excluded_alias_match else None + visible_session_ids = {session_id for session_id, alias in session_aliases.items() if alias != excluded_alias} + if "COUNT(*) AS total_count" in sql_query: + return [{"total_count": len(visible_session_ids)}] + if "DISTINCT ON" in sql_query: + requested_sessions = set(params[-2]) + requested_api_keys = set(params[-1]) + return [ + representative + for representative in representatives + if representative["session_id"] in visible_session_ids + and representative["session_id"] in requested_sessions + and representative["api_key"] in requested_api_keys + ] + if "COALESCE(SUM(spend)" in sql_query: + return [] + return [row for row in page_rows if row["session_key"] in visible_session_ids] + + mock_prisma = MagicMock() + mock_prisma.db = MagicMock() + mock_prisma.db.query_raw = AsyncMock(side_effect=mock_query_raw) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) + monkeypatch.setattr( + "litellm.proxy.spend_tracking.spend_management_endpoints._is_admin_view_safe", + lambda user_api_key_dict: True, + ) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user" + ) + try: + start_date, end_date = _default_date_range() + response = client.get( + "/spend/logs/ui", + params={ + "start_date": start_date, + "end_date": end_date, + "group_by_session": "true", + "exclude_key_alias": "noisy", + "page_size": 1, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 200, response.text + assert response.json()["total"] == 2 + assert [row["request_id"] for row in response.json()["data"]] == ["req-2"] + queries = [call.args for call in mock_prisma.db.query_raw.await_args_list] + assert len(queries) >= 3 + for sql, *params in queries[:3]: + alias_placeholder = re.search(r"user_api_key_alias' IS DISTINCT FROM \$(\d+)", sql) + assert alias_placeholder is not None + assert params[int(alias_placeholder.group(1)) - 1] == "noisy" + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + @pytest.mark.asyncio async def test_ui_view_spend_logs_group_by_session_cursor_page(client, monkeypatch): """A session_cursor becomes a keyset predicate on the read's sessions instead of an OFFSET.""" diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 3d30964129d..c939d56039f 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -2137,4 +2137,4 @@ "count": 1 } } -} \ No newline at end of file +} diff --git a/ui/litellm-dashboard/src/components/logs/request/RequestLogsFilters.test.tsx b/ui/litellm-dashboard/src/components/logs/request/RequestLogsFilters.test.tsx index f13e24b268a..1117d59e669 100644 --- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsFilters.test.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsFilters.test.tsx @@ -47,8 +47,8 @@ function renderFilters(filters: Record = {}) { return { set }; } -function StatefulFilters() { - const [filters, setFilters] = useState>({}); +function StatefulFilters({ initialFilters = {} }: { initialFilters?: Record }) { + const [filters, setFilters] = useState>(initialFilters); return ( filters[id]} @@ -187,6 +187,48 @@ describe("RequestLogsFilters", () => { expect(useInfiniteKeyAliases).toHaveBeenCalledWith(50, undefined, undefined); }); + it("switches Key Alias from is to is not while keeping the selected alias", async () => { + const user = userEvent.setup(); + const { set } = renderFilters({ [LOG_FILTER_IDS.KEY_ALIAS]: "noisy" }); + + await user.click(screen.getByRole("combobox", { name: "Key Alias operator" })); + await user.click(await screen.findByRole("option", { name: "is not" })); + + expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.KEY_ALIAS, undefined); + expect(set).toHaveBeenCalledWith("exclude_key_alias", "noisy"); + }); + + it("selects an alias with the is not operator from an empty filter", async () => { + vi.mocked(useInfiniteKeyAliases).mockReturnValue({ + ...emptyInfiniteQuery, + data: { pages: [{ aliases: ["noisy"] }], pageParams: [1] }, + } as unknown as ReturnType); + const user = userEvent.setup(); + const { set } = renderFilters(); + + await user.click(screen.getByRole("combobox", { name: "Key Alias operator" })); + await user.click(await screen.findByRole("option", { name: "is not" })); + await chooseSelectOption(user, screen.getByPlaceholderText("Search a key alias"), "noisy"); + + expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS, "noisy"); + }); + + it("keeps a restored is not operator when clearing the alias to choose another", async () => { + vi.mocked(useInfiniteKeyAliases).mockReturnValue({ + ...emptyInfiniteQuery, + data: { pages: [{ aliases: ["noisy", "quiet"] }], pageParams: [1] }, + } as unknown as ReturnType); + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Clear", hidden: true })); + + expect(screen.getByRole("combobox", { name: "Key Alias operator" })).toHaveTextContent("is not"); + await chooseSelectOption(user, screen.getByPlaceholderText("Search a key alias"), "quiet"); + expect(screen.getByRole("combobox", { name: "Key Alias operator" })).toHaveTextContent("is not"); + expect(screen.getByPlaceholderText("Search a key alias")).toHaveValue("quiet"); + }); + it("does not leak the team scope into the Model lookup", async () => { renderFilters({ [LOG_FILTER_IDS.TEAM_ID]: "team-42" }); diff --git a/ui/litellm-dashboard/src/components/logs/request/RequestLogsFilters.tsx b/ui/litellm-dashboard/src/components/logs/request/RequestLogsFilters.tsx index 2bf893820ce..9b970b6277b 100644 --- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsFilters.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsFilters.tsx @@ -25,6 +25,11 @@ import { CREDENTIAL_LABELS, ERROR_CODE_OPTIONS } from "../constants"; import { LOG_FILTER_IDS, type LogsWindow } from "./useLogFilterLogic"; const ALL_VALUE = "all"; +const KEY_ALIAS_OPERATORS = [ + { value: "is", label: "is" }, + { value: "is not", label: "is not" }, +] as const; +type KeyAliasOperator = (typeof KEY_ALIAS_OPERATORS)[number]["value"]; const STATUS_FILTER_ITEMS = [ { value: ALL_VALUE, label: "All Statuses" }, @@ -91,14 +96,32 @@ function TeamFilterField({ function KeyAliasFilterField({ value, + excludeValue, onChange, + onExcludeChange, teamId, }: { value: string; + excludeValue: string; onChange: (value: string | undefined) => void; + onExcludeChange: (value: string | undefined) => void; teamId: string; }) { const [search, setSearch] = useState(""); + const [operator, setOperator] = useState(excludeValue !== "" ? "is not" : "is"); + let activeOperator: KeyAliasOperator = operator; + if (excludeValue !== "") { + activeOperator = "is not"; + } else if (value !== "") { + activeOperator = "is"; + } + const handleOperatorChange = (next: KeyAliasOperator | null) => { + if (next === null || next === activeOperator) return; + const selectedAlias = value || excludeValue || undefined; + setOperator(next); + onChange(next === "is" ? selectedAlias : undefined); + onExcludeChange(next === "is not" ? selectedAlias : undefined); + }; const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteKeyAliases( PAGE_SIZE, emptyToUndefined(search), @@ -118,10 +141,24 @@ function KeyAliasFilterField({ return ( + onChange(next ?? undefined)} + value={activeOperator === "is not" ? excludeValue : value} + onValueChange={(next) => + activeOperator === "is not" ? onExcludeChange(next ?? undefined) : onChange(next ?? undefined) + } onSearchChange={setSearch} onLoadMore={() => void fetchNextPage()} hasNextPage={hasNextPage} @@ -425,7 +462,9 @@ export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsF diff --git a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx index ce136c1cf77..293198e9163 100644 --- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx @@ -55,10 +55,28 @@ vi.mock("../detail", () => ({ const debounce = vi.hoisted(() => ({ settled: null as string | null })); -vi.mock("@tanstack/react-pacer/debouncer", () => ({ +vi.mock("@tanstack/react-pacer/debouncer", async (importOriginal) => ({ + ...(await importOriginal()), useDebouncedValue: vi.fn((value: unknown) => [debounce.settled ?? value, { cancel: vi.fn(), flush: vi.fn() }]), })); +const emptyAliases = vi.hoisted(() => ({ + data: { pages: [], pageParams: [] }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, +})); +vi.mock("@/app/(dashboard)/hooks/keys/useKeyAliases", () => ({ useInfiniteKeyAliases: () => emptyAliases })); +vi.mock("@/app/(dashboard)/hooks/models/useModels", async (importOriginal) => ({ + ...(await importOriginal()), + useInfiniteModelInfo: () => emptyAliases, +})); +vi.mock("@/app/(dashboard)/hooks/spendLogs/useSpendLogUsers", () => ({ useInfiniteSpendLogUsers: () => emptyAliases })); +vi.mock("@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers", () => ({ + useInfiniteSpendLogEndUsers: () => emptyAliases, +})); + import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import { uiSpendLogsCall } from "../../networking"; @@ -321,6 +339,46 @@ describe("RequestLogsPanel", () => { }); }); + it("drops the cursor and returns to the first page when the alias changes through URL navigation", async () => { + const firstPage = Array.from({ length: 50 }, (_, index) => logEntry({ request_id: `req-${index}` })); + vi.mocked(uiSpendLogsCall).mockImplementation(async ({ page }) => ({ + data: firstPage, + total: 80, + page: page ?? 1, + page_size: 50, + total_pages: 2, + next_session_cursor: "2026-07-07 09:50:13|key-1|sess-1", + has_more: true, + })); + const tree = (searchParams: string) => ( + + + + + + ); + const view = render(tree("")); + + await waitFor(() => expect(row("req-0")).not.toBeNull()); + fireEvent.click(screen.getByTestId("pagination-next")); + await waitFor(() => expect(lastCall()?.page).toBe(2)); + + const callCountBeforeAliasNavigation = vi.mocked(uiSpendLogsCall).mock.calls.length; + view.rerender(tree("exclude_key_alias=noisy")); + + await waitFor(() => { + const aliasCalls = vi + .mocked(uiSpendLogsCall) + .mock.calls.slice(callCountBeforeAliasNavigation) + .map(([options]) => options) + .filter((options) => options.params?.exclude_key_alias === "noisy"); + expect(aliasCalls.length).toBeGreaterThan(0); + expect(aliasCalls.every((options) => options.page === 1 && options.params?.session_cursor === undefined)).toBe( + true, + ); + }); + }); + it("ignores another next click while the next page is still fetching", async () => { const firstPage = Array.from({ length: 50 }, (_, index) => logEntry({ request_id: `req-${index}` })); const firstResponse = { @@ -479,6 +537,78 @@ describe("RequestLogsPanel", () => { }); describe("time range", () => { + it("applies is not to an existing Key Alias and puts the exclusion in the URL", async () => { + const user = userEvent.setup(); + renderPanel("key_alias=noisy"); + + await waitFor(() => expect(lastCall()?.params?.key_alias).toBe("noisy")); + await user.click(screen.getByTestId("datatable-filters-trigger")); + await user.click(screen.getByRole("combobox", { name: "Key Alias operator" })); + await user.click(await screen.findByRole("option", { name: "is not" })); + await user.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => expect(urlParams().get("exclude_key_alias")).toBe("noisy")); + expect(urlParams().get("key_alias")).toBeNull(); + await waitFor(() => expect(lastCall()?.params?.exclude_key_alias).toBe("noisy")); + expect(lastCall()?.params?.key_alias).toBeUndefined(); + }); + + it("restores an excluded Key Alias from a bookmarked URL and clears it on reset", async () => { + const user = userEvent.setup(); + renderPanel("exclude_key_alias=noisy"); + + await waitFor(() => expect(lastCall()?.params?.exclude_key_alias).toBe("noisy")); + expect(screen.getByTestId("filter-chip-exclude_key_alias")).toHaveTextContent("noisy"); + + await user.click(screen.getByRole("button", { name: "Reset Filters" })); + + await waitFor(() => expect(urlParams().get("exclude_key_alias")).toBeNull()); + await waitFor(() => expect(lastCall()?.params?.exclude_key_alias).toBeUndefined()); + }); + + it.each([" noisy ", " "])( + "preserves the exact excluded alias %j when reapplying bookmarked filters", + async (alias) => { + const user = userEvent.setup(); + renderPanel(new URLSearchParams({ exclude_key_alias: alias }).toString()); + + await waitFor(() => expect(lastCall()?.params?.exclude_key_alias).toBe(alias)); + await user.click(screen.getByTestId("datatable-filters-trigger")); + await user.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => expect(urlParams().get("exclude_key_alias")).toBe(alias)); + expect(lastCall()?.params?.exclude_key_alias).toBe(alias); + }, + ); + + it("treats conflicting Key Alias URL parameters as one mutually exclusive filter", async () => { + renderPanel("key_alias=quiet&exclude_key_alias=noisy"); + + await waitFor(() => expect(lastCall()?.params?.exclude_key_alias).toBe("noisy")); + expect(lastCall()?.params?.key_alias).toBeUndefined(); + expect(screen.getByTestId("filter-chip-exclude_key_alias")).toHaveTextContent("noisy"); + expect(screen.queryByTestId("filter-chip-key_alias")).not.toBeInTheDocument(); + }); + + it("keeps an excluded Key Alias on the next page", async () => { + vi.mocked(uiSpendLogsCall).mockResolvedValue({ + data: Array.from({ length: 25 }, (_, index) => logEntry({ request_id: `req-${index}` })), + total: 60, + page: 1, + page_size: 25, + total_pages: 3, + next_session_cursor: "2026-07-07 09:50:13|key-1|sess-1", + has_more: true, + }); + renderPanel("exclude_key_alias=noisy"); + + await waitFor(() => expect(row("req-0")).not.toBeNull()); + fireEvent.click(screen.getByTestId("pagination-next")); + + await waitFor(() => expect(lastCall()?.page).toBe(2)); + expect(lastCall()?.params?.exclude_key_alias).toBe("noisy"); + }); + it("requests a ~15 minute window when Last 15 Minutes is picked", async () => { const user = userEvent.setup(); renderPanel(); diff --git a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx index e29c48b94c0..92376067df8 100644 --- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx @@ -4,7 +4,8 @@ import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; import type { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table"; import moment from "moment"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { parseAsString, useQueryStates } from "nuqs"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DEFAULT_PAGE_SIZE_OPTIONS } from "@/components/shared/DataTable"; import { Button } from "@/components/ui/button"; @@ -17,6 +18,8 @@ import type { LogEntry } from "../types"; import { DEFAULT_LOGS_SORTING, formatLogsWindow, + getExactFilterValue, + getFilterValue, getLogsWindowEndBound, LOG_FILTER_IDS, type PaginatedResponse, @@ -49,8 +52,33 @@ interface RequestLogsPanelProps { export default function RequestLogsPanel({ accessToken, token, userRole, userID, isActive }: RequestLogsPanelProps) { const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: PAGE_SIZE }); const [sorting, setSorting] = useState(DEFAULT_LOGS_SORTING); - const [columnFilters, setColumnFilters] = useState([]); + const [localFilters, setLocalFilters] = useState([]); + const [{ key_alias, exclude_key_alias }, setAliasParams] = useQueryStates( + { key_alias: parseAsString, exclude_key_alias: parseAsString }, + { history: "push" }, + ); + const [observedAliasParams, setObservedAliasParams] = useState({ key_alias, exclude_key_alias }); + const columnFilters = useMemo(() => { + const aliasFilters: ColumnFiltersState = []; + if (exclude_key_alias) { + aliasFilters.push({ id: LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS, value: exclude_key_alias }); + } else if (key_alias) { + aliasFilters.push({ id: LOG_FILTER_IDS.KEY_ALIAS, value: key_alias }); + } + return [...localFilters, ...aliasFilters]; + }, [localFilters, key_alias, exclude_key_alias]); + const columnFiltersRef = useRef(columnFilters); + useEffect(() => { + columnFiltersRef.current = columnFilters; + }, [columnFilters]); const [sessionCursors, setSessionCursors] = useState>({}); + const aliasParamsChanged = + observedAliasParams.key_alias !== key_alias || observedAliasParams.exclude_key_alias !== exclude_key_alias; + const effectivePagination = useMemo( + () => (aliasParamsChanged ? { ...pagination, pageIndex: 0 } : pagination), + [aliasParamsChanged, pagination], + ); + const effectiveSessionCursors = aliasParamsChanged ? {} : sessionCursors; const [timeRange, setTimeRange] = useState(defaultLogsTimeRange); const { startTime, endTime, isCustomDate } = timeRange; @@ -105,10 +133,10 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, excludeInternalHealthChecks, startTime, endTime, - pagination, + pagination: effectivePagination, isCustomDate, sorting, - sessionCursors, + sessionCursors: effectiveSessionCursors, }); // Follow the table's own last fetch so a live-tail refresh carries the filter @@ -173,13 +201,13 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, const isDrawerOpen = displayLog !== null || displaySessionId !== null; const rows: LogEntry[] = filteredLogs.data; - const rowsThroughThisPage = pagination.pageIndex * pagination.pageSize + rows.length; + const rowsThroughThisPage = effectivePagination.pageIndex * effectivePagination.pageSize + rows.length; const isLastPage = filteredLogs.has_more === false || (filteredLogs.has_more === undefined && rows.length < pagination.pageSize); const rowCount = isLastPage ? rowsThroughThisPage : Math.max(filteredLogs.total, rowsThroughThisPage); const handleSearchChange = useCallback((value: string) => { - setColumnFilters((previous) => { + setLocalFilters((previous) => { const others = previous.filter((filter) => filter.id !== LOG_FILTER_IDS.SEARCH); return value === "" ? others : [...others, { id: LOG_FILTER_IDS.SEARCH, value }]; }); @@ -193,30 +221,66 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, setPagination((previous) => ({ ...previous, pageIndex: 0 })); }, []); - const handleColumnFiltersChange = useCallback>((updaterOrValue) => { - setColumnFilters(updaterOrValue); - setSessionCursors({}); - setPagination((previous) => ({ ...previous, pageIndex: 0 })); - }, []); + const handleColumnFiltersChange = useCallback>( + (updaterOrValue) => { + const next = typeof updaterOrValue === "function" ? updaterOrValue(columnFiltersRef.current) : updaterOrValue; + const nextKeyAlias = getFilterValue(next, LOG_FILTER_IDS.KEY_ALIAS); + const nextExcludeKeyAlias = getExactFilterValue(next, LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS); + const normalizedNext = next.filter( + (filter) => filter.id !== LOG_FILTER_IDS.KEY_ALIAS && filter.id !== LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS, + ); + if (nextExcludeKeyAlias !== undefined) { + normalizedNext.push({ id: LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS, value: nextExcludeKeyAlias }); + } else if (nextKeyAlias !== undefined) { + normalizedNext.push({ id: LOG_FILTER_IDS.KEY_ALIAS, value: nextKeyAlias }); + } + columnFiltersRef.current = normalizedNext; + setLocalFilters( + normalizedNext.filter( + (filter) => filter.id !== LOG_FILTER_IDS.KEY_ALIAS && filter.id !== LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS, + ), + ); + void setAliasParams({ + key_alias: nextExcludeKeyAlias === undefined ? nextKeyAlias ?? null : null, + exclude_key_alias: nextExcludeKeyAlias ?? null, + }); + setSessionCursors({}); + setPagination((previous) => ({ ...previous, pageIndex: 0 })); + }, + [setAliasParams], + ); - const resetToFirstPage = useCallback(() => { - setSessionCursors({}); - setPagination((previous) => ({ ...previous, pageIndex: 0 })); - }, []); + const resetToFirstPage = useCallback( + (nextAliasParams?: { key_alias: string | null; exclude_key_alias: string | null }) => { + if (nextAliasParams) { + setObservedAliasParams(nextAliasParams); + } + setSessionCursors({}); + setPagination((previous) => ({ ...previous, pageIndex: 0 })); + }, + [], + ); + + useEffect(() => { + if (!aliasParamsChanged) return; + // Browser history changes arrive outside the table event handlers, so local cursor state must follow the URL. + // eslint-disable-next-line react-hooks/set-state-in-effect + resetToFirstPage({ key_alias, exclude_key_alias }); + }, [aliasParamsChanged, key_alias, exclude_key_alias, resetToFirstPage]); const handlePaginationChange = useCallback>( (updaterOrValue) => { - const requested = typeof updaterOrValue === "function" ? updaterOrValue(pagination) : updaterOrValue; + const requested = typeof updaterOrValue === "function" ? updaterOrValue(effectivePagination) : updaterOrValue; if (!usesSessionCursor) { setPagination(requested); return; } - if (requested.pageSize !== pagination.pageSize) { + if (requested.pageSize !== effectivePagination.pageSize) { setSessionCursors({}); setPagination({ ...requested, pageIndex: 0 }); return; } - if (requested.pageIndex !== pagination.pageIndex + 1) { + if (requested.pageIndex !== effectivePagination.pageIndex + 1) { setPagination(requested); return; } @@ -225,7 +289,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, setSessionCursors((previous) => ({ ...previous, [requested.pageIndex]: nextCursor })); setPagination(requested); }, - [usesSessionCursor, pagination, filteredLogs.next_session_cursor, logsQuery.isPlaceholderData], + [usesSessionCursor, effectivePagination, filteredLogs.next_session_cursor, logsQuery.isPlaceholderData], ); const handleExcludeInternalHealthChecksChange = useCallback( @@ -245,10 +309,11 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, ); const handleResetFilters = useCallback(() => { - setColumnFilters([]); + setLocalFilters([]); + void setAliasParams({ key_alias: null, exclude_key_alias: null }); setTimeRange(defaultLogsTimeRange()); resetToFirstPage(); - }, [resetToFirstPage]); + }, [resetToFirstPage, setAliasParams]); const handleRowClick = useCallback( (log: LogEntry) => { @@ -304,7 +369,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, isError={logsQuery.isError} errorMessage={logsQuery.error instanceof Error ? logsQuery.error.message : undefined} isRefreshing={logsQuery.isFetching} - pagination={pagination} + pagination={effectivePagination} onPaginationChange={handlePaginationChange} sorting={sorting} onSortingChange={handleSortingChange} @@ -322,7 +387,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, - {isLiveTail && pagination.pageIndex === 0 && ( + {isLiveTail && effectivePagination.pageIndex === 0 && ( Refreshing every 15s diff --git a/ui/litellm-dashboard/src/components/logs/request/useLogFilterLogic.test.tsx b/ui/litellm-dashboard/src/components/logs/request/useLogFilterLogic.test.tsx index 9c266759f53..6fe9c9db193 100644 --- a/ui/litellm-dashboard/src/components/logs/request/useLogFilterLogic.test.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/useLogFilterLogic.test.tsx @@ -92,6 +92,9 @@ describe("useLogFilterLogic", () => { { id: LOG_FILTER_IDS.MODEL_ID, value: "model-uuid-1", param: "model_id" }, { id: LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL, value: "gpt-4o", param: "model" }, { id: LOG_FILTER_IDS.KEY_ALIAS, value: "alias-1", param: "key_alias" }, + { id: LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS, value: "alias-1", param: "exclude_key_alias" }, + { id: LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS, value: " alias-1 ", param: "exclude_key_alias" }, + { id: LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS, value: " ", param: "exclude_key_alias" }, { id: LOG_FILTER_IDS.ERROR_CODE, value: "429", param: "error_code" }, { id: LOG_FILTER_IDS.ERROR_MESSAGE, value: "rate limited", param: "error_message" }, { id: LOG_FILTER_IDS.USER_ID, value: "user-9", param: "user_id" }, @@ -110,6 +113,7 @@ describe("useLogFilterLogic", () => { columnFilters: [ { id: LOG_FILTER_IDS.TEAM_ID, value: " " }, { id: LOG_FILTER_IDS.KEY_HASH, value: "" }, + { id: LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS, value: "" }, ], }); @@ -117,6 +121,7 @@ describe("useLogFilterLogic", () => { const params = lastCallParams()?.params; expect(params?.team_id).toBeUndefined(); expect(params?.api_key).toBeUndefined(); + expect(params?.exclude_key_alias).toBeUndefined(); expect(params?.error_code).toBeUndefined(); }); }); diff --git a/ui/litellm-dashboard/src/components/logs/request/useLogFilterLogic.ts b/ui/litellm-dashboard/src/components/logs/request/useLogFilterLogic.ts index 17d7eb4b860..190345958af 100644 --- a/ui/litellm-dashboard/src/components/logs/request/useLogFilterLogic.ts +++ b/ui/litellm-dashboard/src/components/logs/request/useLogFilterLogic.ts @@ -26,6 +26,7 @@ export const LOG_FILTER_IDS = { CACHE_STATUS: "cache_hit", CREDENTIAL: "used_client_oauth_token", KEY_ALIAS: "key_alias", + EXCLUDE_KEY_ALIAS: "exclude_key_alias", END_USER: "end_user", ERROR_CODE: "error_code", ERROR_MESSAGE: "error_message", @@ -45,6 +46,7 @@ export const LOG_FILTER_LABELS: Record = { [LOG_FILTER_IDS.CACHE_STATUS]: "Cache", [LOG_FILTER_IDS.CREDENTIAL]: "Credential", [LOG_FILTER_IDS.KEY_ALIAS]: "Key Alias", + [LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS]: "Key Alias is not", [LOG_FILTER_IDS.USER_ID]: "User ID", [LOG_FILTER_IDS.END_USER]: "End User", [LOG_FILTER_IDS.ERROR_CODE]: "Error Code", @@ -96,10 +98,13 @@ export const DEFAULT_LOGS_SORTING: SortingState = [{ id: "startTime", desc: true const isSortField = (id: string): id is LogsSortField => Object.hasOwn(LOGS_SORT_FIELD_MAP, id); -export const getFilterValue = (columnFilters: ColumnFiltersState, columnId: string): string | undefined => { +export const getExactFilterValue = (columnFilters: ColumnFiltersState, columnId: string): string | undefined => { const entry = columnFilters.find((filter) => filter.id === columnId); - if (typeof entry?.value !== "string") return undefined; - const trimmed = entry.value.trim(); + return typeof entry?.value === "string" && entry.value !== "" ? entry.value : undefined; +}; + +export const getFilterValue = (columnFilters: ColumnFiltersState, columnId: string): string | undefined => { + const trimmed = getExactFilterValue(columnFilters, columnId)?.trim(); return trimmed === "" ? undefined : trimmed; }; @@ -171,7 +176,7 @@ export function useLogFilterLogic({ const userIdFilter = getFilterValue(columnFilters, LOG_FILTER_IDS.USER_ID); - return await uiSpendLogsCall({ + const spendLogsRequest: Parameters[0] = { accessToken, start_date: window.start_date, end_date: window.end_date, @@ -192,6 +197,7 @@ export function useLogFilterLogic({ model_id: getFilterValue(columnFilters, LOG_FILTER_IDS.MODEL_ID), model: getFilterValue(columnFilters, LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL), key_alias: getFilterValue(columnFilters, LOG_FILTER_IDS.KEY_ALIAS), + exclude_key_alias: getExactFilterValue(columnFilters, LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS), error_code: getFilterValue(columnFilters, LOG_FILTER_IDS.ERROR_CODE), error_message: getFilterValue(columnFilters, LOG_FILTER_IDS.ERROR_MESSAGE), sort_by: sortBy, @@ -200,7 +206,8 @@ export function useLogFilterLogic({ group_by_session: true, session_cursor: sessionCursor, }, - }); + }; + return await uiSpendLogsCall(spendLogsRequest); }, enabled: !!accessToken && !!token && !!userRole && !!userID && activeTab === "request logs", refetchInterval: getLiveTailRefetchInterval(isLiveTail, pagination.pageIndex), diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 994c2948346..2838ecde9c0 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1901,6 +1901,7 @@ interface UiSpendLogsParams { /** Filter by model ID (litellm model deployment id) */ model_id?: string; key_alias?: string; + exclude_key_alias?: string; error_code?: string; error_message?: string; sort_by?: string; diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index e2e1210bc2a..1f32b1a54ac 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -73389,6 +73389,8 @@ export interface operations { model_group?: string | null; /** @description Filter logs by key alias */ key_alias?: string | null; + /** @description Exclude logs whose key alias exactly matches this value; logs without an alias remain included */ + exclude_key_alias?: string | null; /** @description Filter logs by end user */ end_user?: string | null; /** @description Filter logs by error code (e.g., '404', '500') */ @@ -73511,6 +73513,8 @@ export interface operations { model_group?: string | null; /** @description Filter logs by key alias */ key_alias?: string | null; + /** @description Exclude logs whose key alias exactly matches this value; logs without an alias remain included */ + exclude_key_alias?: string | null; /** @description Filter logs by end user */ end_user?: string | null; /** @description Filter logs by error code (e.g., '404', '500') */