From d1ce32c4f8af9c87ae4dcaef2426cb6910ef8a7b Mon Sep 17 00:00:00 2001 From: zhengxinliang Date: Mon, 5 Oct 2026 08:41:11 +0800 Subject: [PATCH 1/6] feat(logs): support excluding an exact key alias --- .../spend_management_endpoints.py | 8 ++ .../test_spend_management_endpoints.py | 100 ++++++++++++++++++ ui/litellm-dashboard/eslint-suppressions.json | 82 ++++++-------- .../logs/request/RequestLogsFilters.test.tsx | 26 +++++ .../logs/request/RequestLogsFilters.tsx | 42 +++++++- .../logs/request/RequestLogsPanel.test.tsx | 68 +++++++++++- .../logs/request/RequestLogsPanel.tsx | 45 ++++++-- .../logs/request/useLogFilterLogic.test.tsx | 1 + .../logs/request/useLogFilterLogic.ts | 3 + .../src/components/networking.tsx | 1 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 4 + 11 files changed, 317 insertions(+), 63 deletions(-) diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 48cc684549f..b45f9b0c5fb 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -2548,6 +2548,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( @@ -2908,6 +2912,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 is not None: + sql_conditions.append(f"metadata->>'user_api_key_alias' IS DISTINCT FROM ${p}") + sql_params.append(exclude_key_alias) + p += 1 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 c27ad7ba0bf..bdd6f08f94d 100644 --- a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py @@ -120,6 +120,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) @@ -166,6 +167,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( { @@ -3595,6 +3598,64 @@ 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)), + ) + 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"], + ] + assert all("user_api_key_alias' IS DISTINCT FROM" in query for query in queries if "WHERE" in query) + 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 = [ @@ -7105,6 +7166,45 @@ 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"), + ] + mock_prisma = _session_grouped_mock_prisma(page_rows, 2, [_session_representative_row("req-1", "sess-1")]) + 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 + queries = [call.args for call in mock_prisma.db.query_raw.await_args_list] + assert len(queries) >= 3 + for sql, *params in queries[:3]: + assert "metadata->>'user_api_key_alias' IS DISTINCT FROM $3" in sql + assert params[2] == "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 HAVING keyset predicate instead of an OFFSET.""" diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 74672cf950e..280d3cc4115 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1706,6 +1706,37 @@ "count": 1 } }, + "src/components/logs/detail/LogDetailsDrawer.tsx": { + "no-nested-ternary": { + "count": 2 + }, + "react-hooks/set-state-in-effect": { + "count": 2 + } + }, + "src/components/logs/detail/sections/EvalViewer/EvalViewer.tsx": { + "no-nested-ternary": { + "count": 1 + } + }, + "src/components/logs/detail/sections/GuardrailViewer/CompliancePanel.tsx": { + "no-nested-ternary": { + "count": 2 + }, + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/components/logs/detail/sections/GuardrailViewer/ContentFilterDetails.tsx": { + "no-nested-ternary": { + "count": 1 + } + }, + "src/components/logs/detail/sections/GuardrailViewer/GuardrailViewer.tsx": { + "no-nested-ternary": { + "count": 3 + } + }, "src/components/mcp_server_management/MCPToolPermissions.tsx": { "local/no-complex-jsx-arrow": { "count": 1 @@ -2266,57 +2297,6 @@ "count": 1 } }, - "src/components/logs/detail/sections/EvalViewer/EvalViewer.tsx": { - "no-nested-ternary": { - "count": 1 - } - }, - "src/components/logs/detail/sections/GuardrailViewer/CompliancePanel.tsx": { - "no-nested-ternary": { - "count": 2 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/components/logs/detail/sections/GuardrailViewer/ContentFilterDetails.tsx": { - "no-nested-ternary": { - "count": 1 - } - }, - "src/components/logs/detail/sections/GuardrailViewer/GuardrailViewer.tsx": { - "no-nested-ternary": { - "count": 3 - } - }, - "src/components/logs/detail/LogDetailsDrawer.tsx": { - "no-nested-ternary": { - "count": 2 - }, - "react-hooks/set-state-in-effect": { - "count": 2 - } - }, - "src/components/logs/detail/useKeyboardNavigation.ts": { - "react-hooks/immutability": { - "count": 2 - } - }, - "src/components/logs/types.ts": { - "local/filename-pascal-case": { - "count": 1 - } - }, - "src/components/logs/request/useLogFilterLogic.ts": { - "local/filename-pascal-case": { - "count": 1 - } - }, - "src/components/logs/request/timeRange.ts": { - "local/filename-pascal-case": { - "count": 1 - } - }, "src/components/view_model/model_name_display.tsx": { "local/filename-pascal-case": { "count": 1 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..23ae3ab83df 100644 --- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsFilters.test.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsFilters.test.tsx @@ -187,6 +187,32 @@ 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("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..d289736f72d 100644 --- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsFilters.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsFilters.tsx @@ -25,6 +25,10 @@ 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; const STATUS_FILTER_ITEMS = [ { value: ALL_VALUE, label: "All Statuses" }, @@ -91,14 +95,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<"is" | "is not">("is"); + const selectedOperator = (): "is" | "is not" => { + if (excludeValue !== "") return "is not"; + if (value !== "") return "is"; + return operator; + }; + const activeOperator = selectedOperator(); + const handleOperatorChange = (next: "is" | "is not" | 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 +140,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 +461,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 d950859f22e..710a73d6588 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"; @@ -479,6 +497,54 @@ 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("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 605ff373c7c..ac5f62b0f03 100644 --- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx @@ -4,6 +4,7 @@ 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 { parseAsString, useQueryStates } from "nuqs"; import { useCallback, useEffect, useMemo, useState } from "react"; import { DEFAULT_PAGE_SIZE_OPTIONS } from "@/components/shared/DataTable"; @@ -17,6 +18,7 @@ import type { LogEntry } from "../types"; import { DEFAULT_LOGS_SORTING, formatLogsWindow, + getFilterValue, getLogsWindowEndBound, LOG_FILTER_IDS, type PaginatedResponse, @@ -49,7 +51,19 @@ 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 columnFilters = useMemo( + () => [ + ...localFilters, + ...(key_alias ? [{ id: LOG_FILTER_IDS.KEY_ALIAS, value: key_alias }] : []), + ...(exclude_key_alias ? [{ id: LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS, value: exclude_key_alias }] : []), + ], + [localFilters, key_alias, exclude_key_alias], + ); const [sessionCursors, setSessionCursors] = useState>({}); const [timeRange, setTimeRange] = useState(defaultLogsTimeRange); @@ -179,7 +193,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, 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,11 +207,23 @@ 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(columnFilters) : updaterOrValue; + setLocalFilters( + next.filter( + (filter) => filter.id !== LOG_FILTER_IDS.KEY_ALIAS && filter.id !== LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS, + ), + ); + void setAliasParams({ + key_alias: getFilterValue(next, LOG_FILTER_IDS.KEY_ALIAS) ?? null, + exclude_key_alias: getFilterValue(next, LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS) ?? null, + }); + setSessionCursors({}); + setPagination((previous) => ({ ...previous, pageIndex: 0 })); + }, + [columnFilters, setAliasParams], + ); const resetToFirstPage = useCallback(() => { setSessionCursors({}); @@ -245,10 +271,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) => { 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..b79a8b2904e 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,7 @@ 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: "exclude_key_alias", value: "alias-1", 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" }, diff --git a/ui/litellm-dashboard/src/components/logs/request/useLogFilterLogic.ts b/ui/litellm-dashboard/src/components/logs/request/useLogFilterLogic.ts index 17d7eb4b860..19016d461a4 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", @@ -192,6 +194,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: getFilterValue(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, diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 98f3a120a49..c39da19b16a 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1874,6 +1874,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 34cde5bbef6..4b8b9c4f666 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -72108,6 +72108,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') */ @@ -72230,6 +72232,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') */ From a1eb204c7e9c307b5ef5857b4000381b9a36d3aa Mon Sep 17 00:00:00 2001 From: zhengxinliang Date: Mon, 5 Oct 2026 18:52:32 +0800 Subject: [PATCH 2/6] fix(logs): address key alias filter review feedback --- .../spend_management_endpoints.py | 2 +- .../test_spend_management_endpoints.py | 16 +++- ui/litellm-dashboard/eslint-suppressions.json | 84 ++++++++++++------- .../logs/request/RequestLogsFilters.tsx | 17 ++-- .../logs/request/RequestLogsPanel.test.tsx | 9 ++ .../logs/request/RequestLogsPanel.tsx | 44 ++++++---- 6 files changed, 113 insertions(+), 59 deletions(-) diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index b45f9b0c5fb..1cd93e3e9c2 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -2912,7 +2912,7 @@ 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 is not None: + 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 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 bdd6f08f94d..f4c93432943 100644 --- a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py @@ -3623,7 +3623,9 @@ async def test_ui_view_spend_logs_excludes_exact_key_alias_before_pagination(cli 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)), + 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" @@ -3651,7 +3653,12 @@ async def test_ui_view_spend_logs_excludes_exact_key_alias_before_pagination(cli ["req1", "req2"], ["req3"], ] - assert all("user_api_key_alias' IS DISTINCT FROM" in query for query in queries if "WHERE" in query) + 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) @@ -7199,8 +7206,9 @@ async def test_ui_view_spend_logs_grouped_exclusion_reaches_page_count_and_repre queries = [call.args for call in mock_prisma.db.query_raw.await_args_list] assert len(queries) >= 3 for sql, *params in queries[:3]: - assert "metadata->>'user_api_key_alias' IS DISTINCT FROM $3" in sql - assert params[2] == "noisy" + 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) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 280d3cc4115..7e4e59e0c9f 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1706,37 +1706,6 @@ "count": 1 } }, - "src/components/logs/detail/LogDetailsDrawer.tsx": { - "no-nested-ternary": { - "count": 2 - }, - "react-hooks/set-state-in-effect": { - "count": 2 - } - }, - "src/components/logs/detail/sections/EvalViewer/EvalViewer.tsx": { - "no-nested-ternary": { - "count": 1 - } - }, - "src/components/logs/detail/sections/GuardrailViewer/CompliancePanel.tsx": { - "no-nested-ternary": { - "count": 2 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/components/logs/detail/sections/GuardrailViewer/ContentFilterDetails.tsx": { - "no-nested-ternary": { - "count": 1 - } - }, - "src/components/logs/detail/sections/GuardrailViewer/GuardrailViewer.tsx": { - "no-nested-ternary": { - "count": 3 - } - }, "src/components/mcp_server_management/MCPToolPermissions.tsx": { "local/no-complex-jsx-arrow": { "count": 1 @@ -2297,6 +2266,57 @@ "count": 1 } }, + "src/components/logs/detail/sections/EvalViewer/EvalViewer.tsx": { + "no-nested-ternary": { + "count": 1 + } + }, + "src/components/logs/detail/sections/GuardrailViewer/CompliancePanel.tsx": { + "no-nested-ternary": { + "count": 2 + }, + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/components/logs/detail/sections/GuardrailViewer/ContentFilterDetails.tsx": { + "no-nested-ternary": { + "count": 1 + } + }, + "src/components/logs/detail/sections/GuardrailViewer/GuardrailViewer.tsx": { + "no-nested-ternary": { + "count": 3 + } + }, + "src/components/logs/detail/LogDetailsDrawer.tsx": { + "no-nested-ternary": { + "count": 2 + }, + "react-hooks/set-state-in-effect": { + "count": 2 + } + }, + "src/components/logs/detail/useKeyboardNavigation.ts": { + "react-hooks/immutability": { + "count": 2 + } + }, + "src/components/logs/types.ts": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/logs/request/useLogFilterLogic.ts": { + "local/filename-pascal-case": { + "count": 1 + } + }, + "src/components/logs/request/timeRange.ts": { + "local/filename-pascal-case": { + "count": 1 + } + }, "src/components/view_model/model_name_display.tsx": { "local/filename-pascal-case": { "count": 1 @@ -2394,4 +2414,4 @@ "count": 1 } } -} \ No newline at end of file +} diff --git a/ui/litellm-dashboard/src/components/logs/request/RequestLogsFilters.tsx b/ui/litellm-dashboard/src/components/logs/request/RequestLogsFilters.tsx index d289736f72d..5822a8d0b5f 100644 --- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsFilters.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsFilters.tsx @@ -29,6 +29,7 @@ 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" }, @@ -107,14 +108,14 @@ function KeyAliasFilterField({ teamId: string; }) { const [search, setSearch] = useState(""); - const [operator, setOperator] = useState<"is" | "is not">("is"); - const selectedOperator = (): "is" | "is not" => { - if (excludeValue !== "") return "is not"; - if (value !== "") return "is"; - return operator; - }; - const activeOperator = selectedOperator(); - const handleOperatorChange = (next: "is" | "is not" | null) => { + const [operator, setOperator] = useState("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); 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 710a73d6588..16f6ce77866 100644 --- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx @@ -526,6 +526,15 @@ describe("RequestLogsPanel", () => { await waitFor(() => expect(lastCall()?.params?.exclude_key_alias).toBeUndefined()); }); + 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}` })), diff --git a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx index ac5f62b0f03..c25858f34c0 100644 --- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx @@ -5,7 +5,7 @@ import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; import type { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table"; import moment from "moment"; import { parseAsString, useQueryStates } from "nuqs"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DEFAULT_PAGE_SIZE_OPTIONS } from "@/components/shared/DataTable"; import { Button } from "@/components/ui/button"; @@ -56,14 +56,19 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, { key_alias: parseAsString, exclude_key_alias: parseAsString }, { history: "push" }, ); - const columnFilters = useMemo( - () => [ - ...localFilters, - ...(key_alias ? [{ id: LOG_FILTER_IDS.KEY_ALIAS, value: key_alias }] : []), - ...(exclude_key_alias ? [{ id: LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS, value: exclude_key_alias }] : []), - ], - [localFilters, 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 [timeRange, setTimeRange] = useState(defaultLogsTimeRange); @@ -209,20 +214,31 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, const handleColumnFiltersChange = useCallback>( (updaterOrValue) => { - const next = typeof updaterOrValue === "function" ? updaterOrValue(columnFilters) : updaterOrValue; + const next = typeof updaterOrValue === "function" ? updaterOrValue(columnFiltersRef.current) : updaterOrValue; + const nextKeyAlias = getFilterValue(next, LOG_FILTER_IDS.KEY_ALIAS); + const nextExcludeKeyAlias = getFilterValue(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( - next.filter( + normalizedNext.filter( (filter) => filter.id !== LOG_FILTER_IDS.KEY_ALIAS && filter.id !== LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS, ), ); void setAliasParams({ - key_alias: getFilterValue(next, LOG_FILTER_IDS.KEY_ALIAS) ?? null, - exclude_key_alias: getFilterValue(next, LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS) ?? null, + key_alias: nextExcludeKeyAlias === undefined ? nextKeyAlias ?? null : null, + exclude_key_alias: nextExcludeKeyAlias ?? null, }); setSessionCursors({}); setPagination((previous) => ({ ...previous, pageIndex: 0 })); }, - [columnFilters, setAliasParams], + [setAliasParams], ); const resetToFirstPage = useCallback(() => { From 7a4848912f894bd94ef508ae6b247d431a772691 Mon Sep 17 00:00:00 2001 From: zhengxinliang Date: Mon, 5 Oct 2026 20:15:38 +0800 Subject: [PATCH 3/6] fix(logs): address alias pagination review feedback --- .../test_spend_management_endpoints.py | 33 ++++++++++++- .../logs/request/RequestLogsPanel.test.tsx | 34 ++++++++++++++ .../logs/request/RequestLogsPanel.tsx | 47 ++++++++++++++----- 3 files changed, 100 insertions(+), 14 deletions(-) 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 f4c93432943..f4bfca9e8e8 100644 --- a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py @@ -7178,8 +7178,38 @@ async def test_ui_view_spend_logs_grouped_exclusion_reaches_page_count_and_repre 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"), ] - mock_prisma = _session_grouped_mock_prisma(page_rows, 2, [_session_representative_row("req-1", "sess-1")]) + 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", @@ -7203,6 +7233,7 @@ async def test_ui_view_spend_logs_grouped_exclusion_reaches_page_count_and_repre ) 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]: 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 16f6ce77866..55f119baadd 100644 --- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx @@ -339,6 +339,40 @@ 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)); + + view.rerender(tree("exclude_key_alias=noisy")); + + await waitFor(() => { + const call = lastCall(); + expect(call?.page).toBe(1); + expect(call?.params?.exclude_key_alias).toBe("noisy"); + expect(call?.params?.session_cursor).toBeUndefined(); + }); + }); + 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 = { diff --git a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx index c25858f34c0..70988223027 100644 --- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx @@ -56,6 +56,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, { 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) { @@ -70,6 +71,13 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, 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; @@ -124,10 +132,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 @@ -192,7 +200,7 @@ 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); @@ -241,24 +249,37 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, [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; } @@ -267,7 +288,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( @@ -345,7 +366,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, rowCount={rowCount} isLoading={logsQuery.isLoading} isRefreshing={logsQuery.isFetching} - pagination={pagination} + pagination={effectivePagination} onPaginationChange={handlePaginationChange} sorting={sorting} onSortingChange={handleSortingChange} @@ -363,7 +384,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, - {isLiveTail && pagination.pageIndex === 0 && ( + {isLiveTail && effectivePagination.pageIndex === 0 && ( Refreshing every 15s From c875836ed533f5acc781cb0ebc870ce50828ef34 Mon Sep 17 00:00:00 2001 From: zhengxinliang Date: Mon, 5 Oct 2026 20:20:05 +0800 Subject: [PATCH 4/6] test(logs): reject stale cursor on alias navigation --- .../logs/request/RequestLogsPanel.test.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) 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 55f119baadd..6c97bf91b26 100644 --- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx @@ -363,13 +363,19 @@ describe("RequestLogsPanel", () => { 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 call = lastCall(); - expect(call?.page).toBe(1); - expect(call?.params?.exclude_key_alias).toBe("noisy"); - expect(call?.params?.session_cursor).toBeUndefined(); + 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, + ); }); }); From 333fd5b773f3c7e7d607af5b490a8d0dd6c9fb89 Mon Sep 17 00:00:00 2001 From: anastasio Date: Tue, 13 Oct 2026 13:48:07 +0800 Subject: [PATCH 5/6] fix(logs): preserve exact alias exclusions when editing filters --- .../logs/request/RequestLogsFilters.test.tsx | 20 +++++++++++++++++-- .../logs/request/RequestLogsFilters.tsx | 2 +- .../logs/request/RequestLogsPanel.test.tsx | 15 ++++++++++++++ .../logs/request/RequestLogsPanel.tsx | 3 ++- .../logs/request/useLogFilterLogic.test.tsx | 6 +++++- .../logs/request/useLogFilterLogic.ts | 11 ++++++---- 6 files changed, 48 insertions(+), 9 deletions(-) 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 23ae3ab83df..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]} @@ -213,6 +213,22 @@ describe("RequestLogsFilters", () => { 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 5822a8d0b5f..9b970b6277b 100644 --- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsFilters.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsFilters.tsx @@ -108,7 +108,7 @@ function KeyAliasFilterField({ teamId: string; }) { const [search, setSearch] = useState(""); - const [operator, setOperator] = useState("is"); + const [operator, setOperator] = useState(excludeValue !== "" ? "is not" : "is"); let activeOperator: KeyAliasOperator = operator; if (excludeValue !== "") { activeOperator = "is not"; 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 6c97bf91b26..cf5c780f943 100644 --- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.test.tsx @@ -566,6 +566,21 @@ describe("RequestLogsPanel", () => { 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"); diff --git a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx index 70988223027..7f79b6f601f 100644 --- a/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx +++ b/ui/litellm-dashboard/src/components/logs/request/RequestLogsPanel.tsx @@ -18,6 +18,7 @@ import type { LogEntry } from "../types"; import { DEFAULT_LOGS_SORTING, formatLogsWindow, + getExactFilterValue, getFilterValue, getLogsWindowEndBound, LOG_FILTER_IDS, @@ -224,7 +225,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, (updaterOrValue) => { const next = typeof updaterOrValue === "function" ? updaterOrValue(columnFiltersRef.current) : updaterOrValue; const nextKeyAlias = getFilterValue(next, LOG_FILTER_IDS.KEY_ALIAS); - const nextExcludeKeyAlias = getFilterValue(next, LOG_FILTER_IDS.EXCLUDE_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, ); 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 b79a8b2904e..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,7 +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: "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: " 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" }, @@ -111,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: "" }, ], }); @@ -118,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 19016d461a4..fa2d9c432c2 100644 --- a/ui/litellm-dashboard/src/components/logs/request/useLogFilterLogic.ts +++ b/ui/litellm-dashboard/src/components/logs/request/useLogFilterLogic.ts @@ -98,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; }; @@ -194,7 +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: getFilterValue(columnFilters, LOG_FILTER_IDS.EXCLUDE_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, From 01dde982363ba6a8fc55b2ec7752606e5188a9f1 Mon Sep 17 00:00:00 2001 From: zhengxinliang Date: Thu, 15 Oct 2026 12:43:30 +0800 Subject: [PATCH 6/6] fix(logs): satisfy current lint gates --- litellm/proxy/spend_tracking/spend_management_endpoints.py | 2 +- .../src/components/logs/request/useLogFilterLogic.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index db01731c8f9..53f9e4427cf 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -2918,7 +2918,7 @@ async def ui_view_spend_logs( 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 + 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/ui/litellm-dashboard/src/components/logs/request/useLogFilterLogic.ts b/ui/litellm-dashboard/src/components/logs/request/useLogFilterLogic.ts index fa2d9c432c2..190345958af 100644 --- a/ui/litellm-dashboard/src/components/logs/request/useLogFilterLogic.ts +++ b/ui/litellm-dashboard/src/components/logs/request/useLogFilterLogic.ts @@ -176,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, @@ -206,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),