From d1ce32c4f8af9c87ae4dcaef2426cb6910ef8a7b Mon Sep 17 00:00:00 2001 From: zhengxinliang Date: Mon, 5 Oct 2026 08:41:11 +0800 Subject: [PATCH] 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') */