From 745f7ad1639d3fc35b26fea8a66a82f4a5b4dfc2 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 24 Jul 2026 15:56:34 -0700 Subject: [PATCH 1/5] perf(ui): back the logs End User filter with a paginated endpoint Opening Logs > Filters fetched the entire customer table through /customer/list, which is an unbounded find_many that eagerly loads the budget and object-permission relations for every row. On a proxy with 61k customers that is a 20 MB, 7.6 s response; the dropdown then built an option per row and rendered all of them, since the combobox does not virtualize. The result was a multi-second freeze every time the drawer opened. Adds GET /customer/aliases, a projection of user_id alone with page/size/ search, mirroring /key/aliases. The End User field now uses PaginatedSearchSelect behind an infinite query, the same shape the Key Alias and Model filters already use, so it fetches 50 rows at a time and pushes the typed query to the server. The response reports has_more instead of a total count. A total needs COUNT(*) over the whole match set on every keystroke, which is the cost this endpoint exists to avoid; ordering by the user_id primary key and fetching one row past the page lets Postgres stop early and still tells the client whether to request more. LIKE metacharacters in the search term are escaped, because end-user ids routinely contain underscores and an unescaped one silently widens the match. Drops the now-unused accessToken prop threaded from RequestLogsPanel through RequestLogsTable into the filters. --- litellm/proxy/_types.py | 1 + .../customer_endpoints.py | 94 ++++++++++- .../customer_endpoints.py | 19 +++ .../test_customer_endpoints.py | 157 ++++++++++++++++++ .../hooks/customers/useEndUserAliases.ts | 18 ++ .../view_logs/RequestLogsFilters.test.tsx | 69 +++++++- .../view_logs/RequestLogsFilters.tsx | 58 +++---- .../components/view_logs/RequestLogsPanel.tsx | 1 - .../components/view_logs/RequestLogsTable.tsx | 4 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 89 ++++++++++ 10 files changed, 464 insertions(+), 46 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useEndUserAliases.ts diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 98efadc10a8..ef160a68656 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -818,6 +818,7 @@ class LiteLLMRoutes(enum.Enum): # Customer / end-user listing (handlers already gate on # PROXY_ADMIN_VIEW_ONLY — the route gate must match). "/customer/list", + "/customer/aliases", "/customer/info", # UI Logs page detail drawer (single + session). The list endpoint # `/spend/logs/ui` is covered via spend_tracking_routes below. diff --git a/litellm/proxy/management_endpoints/customer_endpoints.py b/litellm/proxy/management_endpoints/customer_endpoints.py index 84f67bdc3bc..0d1ea994ae9 100644 --- a/litellm/proxy/management_endpoints/customer_endpoints.py +++ b/litellm/proxy/management_endpoints/customer_endpoints.py @@ -11,10 +11,10 @@ All /customer management endpoints #### END-USER/CUSTOMER MANAGEMENT #### from datetime import datetime, timedelta -from typing import List, Optional +from typing import Annotated, Any, List, Optional import fastapi -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel import litellm @@ -35,6 +35,7 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import ( ) from litellm.types.proxy.management_endpoints.customer_endpoints import ( BlockUsersResponse, + CustomerAliasesResponse, CustomerResponse, DeleteCustomersResponse, UnblockUsersResponse, @@ -785,6 +786,95 @@ async def list_end_user( raise handle_exception_on_proxy(e) +def _require_customer_read_access(user_api_key_dict: UserAPIKeyAuth) -> None: + if user_api_key_dict.user_role not in ( + LitellmUserRoles.PROXY_ADMIN, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + ): + raise HTTPException( + status_code=401, + detail={"error": "Admin-only endpoint. Your user role={}".format(user_api_key_dict.user_role)}, + ) + + +@router.get( + "/customer/aliases", + tags=["Customer Management"], + dependencies=[Depends(user_api_key_auth)], + response_model=CustomerAliasesResponse, +) +async def list_customer_aliases( + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + page: Annotated[int, Query(ge=1, description="Page number")] = 1, + size: Annotated[int, Query(ge=1, le=100, description="Page size")] = 50, + search: Annotated[ + str | None, + Query(description="Case-insensitive partial match on the customer id"), + ] = None, +) -> CustomerAliasesResponse: + """ + [Admin-only] List customer ids with pagination and optional search. + + Lightweight counterpart to `/customer/list`, for UI filter dropdowns. + `/customer/list` returns every customer with its budget and object-permission + relations eagerly loaded, which is unusable once LiteLLM_EndUserTable grows + (end-user rows are created automatically per distinct `user` seen in traffic). + + Example curl: + ``` + curl --location 'http://0.0.0.0:4000/customer/aliases?page=1&size=50&search=acme' \ + --header 'Authorization: Bearer sk-1234' + ``` + """ + try: + from litellm.proxy.proxy_server import prisma_client + + _require_customer_read_access(user_api_key_dict) + + if prisma_client is None: + raise HTTPException( + status_code=400, + detail={"error": CommonProxyErrors.db_not_connected_error.value}, + ) + + where_parts = ["user_id IS NOT NULL", "user_id != ''"] + query_params: List[Any] = [] + + if search: + # Escape LIKE metacharacters so a literal '_' or '%' matches itself. + escaped = search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + query_params.append(f"%{escaped}%") + where_parts.append(f"user_id ILIKE ${len(query_params)} ESCAPE '\\'") + + where_sql = " AND ".join(where_parts) + + # size + 1: one row beyond the page reveals has_more without a COUNT(*). + limit_params = query_params + [size + 1, (page - 1) * size] + aliases_sql = ( + f"SELECT user_id" + f' FROM "LiteLLM_EndUserTable"' + f" WHERE {where_sql}" + f" ORDER BY user_id ASC" + f" LIMIT ${len(limit_params) - 1} OFFSET ${len(limit_params)}" + ) + rows = await prisma_client.db.query_raw(aliases_sql, *limit_params) + aliases: List[str] = [row["user_id"] for row in rows if row.get("user_id")] + + return CustomerAliasesResponse( + aliases=aliases[:size], + current_page=page, + size=size, + has_more=len(aliases) > size, + ) + + except Exception as e: + verbose_proxy_logger.exception( + "litellm.proxy.management_endpoints.customer_endpoints.list_customer_aliases(): " + "Exception occured - {}".format(str(e)) + ) + raise handle_exception_on_proxy(e) + + @router.get( "/customer/daily/activity", tags=["Customer Management"], diff --git a/litellm/types/proxy/management_endpoints/customer_endpoints.py b/litellm/types/proxy/management_endpoints/customer_endpoints.py index e7653360d63..93d042fcea1 100644 --- a/litellm/types/proxy/management_endpoints/customer_endpoints.py +++ b/litellm/types/proxy/management_endpoints/customer_endpoints.py @@ -17,6 +17,25 @@ class CustomerResponse(LiteLLM_EndUserTable): litellm_budget_table: Optional[LiteLLM_BudgetTableFull] = None # pyright: ignore +class CustomerAliasesResponse(BaseModel): + """Paginated, id-only customer listing used by UI filter dropdowns. + + Deliberately excludes budget/object-permission relations so a proxy with a + large LiteLLM_EndUserTable can back a search-as-you-type control without + materializing every row (see /customer/list for the full objects). + + Reports ``has_more`` rather than a total count on purpose: a total requires + COUNT(*) over the whole match set on every keystroke, which is the exact + cost this endpoint exists to avoid. Fetching one row beyond the page is + enough to drive an infinite-scroll dropdown. + """ + + aliases: List[str] + current_page: int + size: int + has_more: bool + + class BlockUsersResponse(BaseModel): blocked_users: List[LiteLLM_EndUserTable] diff --git a/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py index 5fbc3c4869b..891b3454af8 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py @@ -782,3 +782,160 @@ def test_char_delete_body(mock_prisma_client, mock_user_api_key_auth): "deleted_customers": 2, "message": "Successfully deleted customers with ids: ['c1', 'c2']", } + + +def _mock_alias_rows(mock_prisma_client, user_ids: List[str]) -> AsyncMock: + query_raw = AsyncMock(return_value=[{"user_id": uid} for uid in user_ids]) + mock_prisma_client.db.query_raw = query_raw + return query_raw + + +def test_customer_aliases_projects_only_user_id_and_never_loads_relations( + mock_prisma_client, mock_user_api_key_auth +): + """The whole point of this endpoint: no full rows, no eager relations. + + /customer/list does find_many(include={budget, object_permission}) over the + entire table; this must stay a single-column, bounded query. + """ + query_raw = _mock_alias_rows(mock_prisma_client, ["a", "b"]) + + response = client.get("/customer/aliases", headers={"Authorization": "Bearer k"}) + + assert response.status_code == 200 + assert response.json() == { + "aliases": ["a", "b"], + "current_page": 1, + "size": 50, + "has_more": False, + } + mock_prisma_client.db.litellm_endusertable.find_many.assert_not_called() + sql = query_raw.call_args.args[0] + assert "SELECT user_id" in sql + assert '"LiteLLM_EndUserTable"' in sql + assert "JOIN" not in sql.upper() + assert "COUNT(" not in sql.upper() + + +def test_customer_aliases_fetches_one_extra_row_and_trims_it(mock_prisma_client, mock_user_api_key_auth): + """has_more is derived from a size+1 fetch; the sentinel row must not leak.""" + query_raw = _mock_alias_rows(mock_prisma_client, [f"u{i}" for i in range(4)]) + + response = client.get("/customer/aliases?size=3", headers={"Authorization": "Bearer k"}) + + assert response.status_code == 200 + body = response.json() + assert body["aliases"] == ["u0", "u1", "u2"] + assert body["has_more"] is True + assert query_raw.call_args.args[1:] == (4, 0) + + +def test_customer_aliases_reports_no_more_pages_on_a_short_page(mock_prisma_client, mock_user_api_key_auth): + _mock_alias_rows(mock_prisma_client, ["u0", "u1"]) + + response = client.get("/customer/aliases?size=3", headers={"Authorization": "Bearer k"}) + + assert response.status_code == 200 + assert response.json()["has_more"] is False + + +def test_customer_aliases_reports_no_more_pages_on_an_exactly_full_page(mock_prisma_client, mock_user_api_key_auth): + _mock_alias_rows(mock_prisma_client, ["u0", "u1", "u2"]) + + response = client.get("/customer/aliases?size=3", headers={"Authorization": "Bearer k"}) + + assert response.status_code == 200 + assert response.json()["aliases"] == ["u0", "u1", "u2"] + assert response.json()["has_more"] is False + + +def test_customer_aliases_offsets_by_page(mock_prisma_client, mock_user_api_key_auth): + query_raw = _mock_alias_rows(mock_prisma_client, []) + + response = client.get("/customer/aliases?page=3&size=25", headers={"Authorization": "Bearer k"}) + + assert response.status_code == 200 + assert response.json()["current_page"] == 3 + assert query_raw.call_args.args[1:] == (26, 50) + + +def test_customer_aliases_without_search_issues_no_like_filter(mock_prisma_client, mock_user_api_key_auth): + query_raw = _mock_alias_rows(mock_prisma_client, []) + + client.get("/customer/aliases", headers={"Authorization": "Bearer k"}) + + sql = query_raw.call_args.args[0] + assert "ILIKE" not in sql.upper() + assert query_raw.call_args.args[1:] == (51, 0) + + +def test_customer_aliases_search_escapes_like_metacharacters(mock_prisma_client, mock_user_api_key_auth): + """End-user ids routinely contain '_'; an unescaped one is a wildcard. + + Without ESCAPE, searching 'device_id' also matches 'deviceXid'. + """ + query_raw = _mock_alias_rows(mock_prisma_client, []) + + client.get("/customer/aliases?search=device_id%25", headers={"Authorization": "Bearer k"}) + + sql = query_raw.call_args.args[0] + assert "ILIKE $1 ESCAPE" in sql + assert query_raw.call_args.args[1] == r"%device\_id\%%" + assert query_raw.call_args.args[2:] == (51, 0) + + +def test_customer_aliases_search_placeholder_precedes_limit_and_offset(mock_prisma_client, mock_user_api_key_auth): + query_raw = _mock_alias_rows(mock_prisma_client, []) + + client.get("/customer/aliases?search=acme&size=10", headers={"Authorization": "Bearer k"}) + + sql = query_raw.call_args.args[0] + assert "LIMIT $2 OFFSET $3" in sql + assert query_raw.call_args.args[1:] == ("%acme%", 11, 0) + + +@pytest.mark.parametrize( + "role", + [ + LitellmUserRoles.INTERNAL_USER, + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, + LitellmUserRoles.TEAM, + LitellmUserRoles.CUSTOMER, + ], +) +def test_customer_aliases_rejects_non_admin_roles(mock_prisma_client, role): + """Mirrors /customer/list: this exposes every customer id on the proxy.""" + _mock_alias_rows(mock_prisma_client, ["secret-customer"]) + original_overrides = app.dependency_overrides.copy() + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_id="u", user_role=role) + try: + response = client.get("/customer/aliases", headers={"Authorization": "Bearer k"}) + finally: + app.dependency_overrides = original_overrides + + assert response.status_code == 401 + assert "secret-customer" not in response.text + + +def test_customer_aliases_allows_admin_viewer(mock_prisma_client): + _mock_alias_rows(mock_prisma_client, ["a"]) + original_overrides = app.dependency_overrides.copy() + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + user_id="u", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY + ) + try: + response = client.get("/customer/aliases", headers={"Authorization": "Bearer k"}) + finally: + app.dependency_overrides = original_overrides + + assert response.status_code == 200 + assert response.json()["aliases"] == ["a"] + + +def test_customer_aliases_caps_page_size(mock_prisma_client, mock_user_api_key_auth): + """An unbounded size would reintroduce the very problem this endpoint fixes.""" + _mock_alias_rows(mock_prisma_client, []) + + response = client.get("/customer/aliases?size=100000", headers={"Authorization": "Bearer k"}) + + assert response.status_code == 422 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useEndUserAliases.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useEndUserAliases.ts new file mode 100644 index 00000000000..b28c2328607 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useEndUserAliases.ts @@ -0,0 +1,18 @@ +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { $api } from "@/lib/http/api"; +import type { components } from "@/lib/http/schema"; +import { all_admin_roles } from "@/utils/roles"; + +type EndUserAliasesPage = components["schemas"]["CustomerAliasesResponse"]; + +export const useInfiniteEndUserAliases = (size: number = 50, search?: string) => { + const { accessToken, userRole } = useAuthorized(); + const query = { size, ...(search !== undefined && search !== "" ? { search } : {}) }; + const options = { + pageParamName: "page", + initialPageParam: 1, + getNextPageParam: (lastPage: EndUserAliasesPage) => (lastPage.has_more ? lastPage.current_page + 1 : undefined), + enabled: Boolean(accessToken) && all_admin_roles.includes(userRole ?? ""), + }; + return $api.useInfiniteQuery("get", "/customer/aliases", { params: { query } }, options); +}; diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx index 0e6e60ad05d..896c3767203 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx @@ -1,4 +1,5 @@ import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders, testQueryClient } from "../../../tests/test-utils"; @@ -13,11 +14,11 @@ vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({ useInfiniteModelInfo: vi.fn(), })); -vi.mock("../networking", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, allEndUsersCall: vi.fn().mockResolvedValue([]) }; -}); +vi.mock("@/app/(dashboard)/hooks/customers/useEndUserAliases", () => ({ + useInfiniteEndUserAliases: vi.fn(), +})); +import { useInfiniteEndUserAliases } from "@/app/(dashboard)/hooks/customers/useEndUserAliases"; import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases"; import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels"; @@ -31,9 +32,7 @@ const emptyInfiniteQuery = { function renderFilters(filters: Record = {}) { const set = vi.fn(); - renderWithProviders( - filters[id]} set={set} teams={[]} accessToken="test-token" />, - ); + renderWithProviders( filters[id]} set={set} teams={[]} />); return { set }; } @@ -47,6 +46,9 @@ describe("RequestLogsFilters", () => { vi.mocked(useInfiniteModelInfo).mockReturnValue( emptyInfiniteQuery as unknown as ReturnType, ); + vi.mocked(useInfiniteEndUserAliases).mockReturnValue( + emptyInfiniteQuery as unknown as ReturnType, + ); }); it("renders every backend-supported filter field", async () => { @@ -88,4 +90,57 @@ describe("RequestLogsFilters", () => { await waitFor(() => expect(useInfiniteModelInfo).toHaveBeenCalled()); expect(useInfiniteModelInfo).toHaveBeenCalledWith(50, undefined); }); + + it("asks the server for a bounded page of end users instead of the whole customer table", async () => { + renderFilters(); + + await waitFor(() => expect(useInfiniteEndUserAliases).toHaveBeenCalled()); + expect(useInfiniteEndUserAliases).toHaveBeenCalledWith(50, undefined); + }); + + it("pushes the End User query to the server rather than filtering a preloaded list", async () => { + const user = userEvent.setup(); + renderFilters(); + + const input = await screen.findByPlaceholderText("Search an end user"); + await user.click(input); + await user.type(input, "acme"); + + await waitFor(() => expect(useInfiniteEndUserAliases).toHaveBeenCalledWith(50, "acme")); + }); + + it("renders only the end users the current page returned", async () => { + vi.mocked(useInfiniteEndUserAliases).mockReturnValue({ + ...emptyInfiniteQuery, + data: { pages: [{ aliases: ["cust-a", "cust-b"], current_page: 1, size: 50, has_more: true }], pageParams: [1] }, + } as unknown as ReturnType); + const user = userEvent.setup(); + renderFilters(); + + await user.click(await screen.findByPlaceholderText("Search an end user")); + + expect(await screen.findByText("cust-a")).toBeInTheDocument(); + expect(screen.getByText("cust-b")).toBeInTheDocument(); + }); + + it("loads the next page when the End User list is scrolled near the end", async () => { + const fetchNextPage = vi.fn(); + vi.mocked(useInfiniteEndUserAliases).mockReturnValue({ + ...emptyInfiniteQuery, + fetchNextPage, + hasNextPage: true, + data: { pages: [{ aliases: ["cust-a"], current_page: 1, size: 50, has_more: true }], pageParams: [1] }, + } as unknown as ReturnType); + const user = userEvent.setup(); + renderFilters(); + + await user.click(await screen.findByPlaceholderText("Search an end user")); + const list = await screen.findByTestId("paginated-search-select-list"); + Object.defineProperty(list, "scrollTop", { value: 90, configurable: true }); + Object.defineProperty(list, "clientHeight", { value: 10, configurable: true }); + Object.defineProperty(list, "scrollHeight", { value: 100, configurable: true }); + list.dispatchEvent(new Event("scroll", { bubbles: true })); + + await waitFor(() => expect(fetchNextPage).toHaveBeenCalled()); + }); }); diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx index ec20f4d0e47..e79481979b3 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx @@ -1,8 +1,8 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; import { useMemo, useState } from "react"; +import { useInfiniteEndUserAliases } from "@/app/(dashboard)/hooks/customers/useEndUserAliases"; import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases"; import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels"; import { DataTableFilterField } from "@/components/shared/DataTable"; @@ -20,7 +20,6 @@ import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import type { Team } from "../key_team_helpers/key_list"; -import { allEndUsersCall } from "../networking"; import { ERROR_CODE_OPTIONS } from "./constants"; import { LOG_FILTER_IDS } from "./log_filter_logic"; @@ -145,37 +144,35 @@ function ModelFilterField({ value, onChange }: { value: string; onChange: (value ); } -function EndUserFilterField({ - value, - onChange, - accessToken, -}: { - value: string; - onChange: (value: string | undefined) => void; - accessToken: string; -}) { - const { data } = useQuery({ - queryKey: ["logFilterEndUsers", accessToken], - queryFn: async () => { - const endUsers = await allEndUsersCall(accessToken); - return (endUsers ?? []).flatMap((endUser: { user_id?: string }) => - typeof endUser.user_id === "string" ? [endUser.user_id] : [], - ); - }, - enabled: accessToken !== "", - }); - - const options = useMemo( - () => (data ?? []).map((userId) => ({ label: userId, value: userId })), - [data], +function EndUserFilterField({ value, onChange }: { value: string; onChange: (value: string | undefined) => void }) { + const [search, setSearch] = useState(""); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteEndUserAliases( + PAGE_SIZE, + emptyToUndefined(search), ); + const options = useMemo(() => { + const seen = new Set(); + return (data?.pages ?? []).flatMap((page) => + page.aliases.flatMap((alias) => { + if (!alias || seen.has(alias)) return []; + seen.add(alias); + return [{ label: alias, value: alias }]; + }), + ); + }, [data]); + return ( - onChange(emptyToUndefined(next))} + onSearchChange={setSearch} + onLoadMore={() => void fetchNextPage()} + hasNextPage={hasNextPage} + isLoading={isLoading} + isFetchingNextPage={isFetchingNextPage} placeholder="Search an end user" emptyText="No end users found" /> @@ -236,10 +233,9 @@ interface RequestLogsFiltersProps { get: (columnId: string) => unknown; set: (columnId: string, value: unknown) => void; teams: Team[]; - accessToken: string; } -export function RequestLogsFilters({ get, set, teams, accessToken }: RequestLogsFiltersProps) { +export function RequestLogsFilters({ get, set, teams }: RequestLogsFiltersProps) { const valueOf = (id: string): string => asString(get(id)); const setter = (id: string) => (next: string | undefined) => set(id, next); @@ -273,11 +269,7 @@ export function RequestLogsFilters({ get, set, teams, accessToken }: RequestLogs teamId={valueOf(LOG_FILTER_IDS.TEAM_ID)} /> - + diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx index 1348c6cea56..5669b67e00a 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx @@ -232,7 +232,6 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, onKeyHashClick={handleKeyHashClick} onSessionClick={handleSessionClick} teams={allTeams ?? []} - accessToken={accessToken} toolbarChildren={ void; onSessionClick: (sessionId: string) => void; teams: Team[]; - accessToken: string; toolbarChildren?: ReactNode; } @@ -68,7 +67,6 @@ export function RequestLogsTable({ onKeyHashClick, onSessionClick, teams, - accessToken, toolbarChildren, }: RequestLogsTableProps) { const [filtersOpen, setFiltersOpen] = useState(false); @@ -122,7 +120,7 @@ export function RequestLogsTable({ title="Filters" description="Narrow down request logs" > - {({ get, set }) => } + {({ get, set }) => } )} diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index aafab811b83..ee2f549d3f2 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -2772,6 +2772,36 @@ export interface paths { patch: operations["cursor_proxy_route_cursor__endpoint__patch"]; trace?: never; }; + "/customer/aliases": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Customer Aliases + * @description [Admin-only] List customer ids with pagination and optional search. + * + * Lightweight counterpart to `/customer/list`, for UI filter dropdowns. + * `/customer/list` returns every customer with its budget and object-permission + * relations eagerly loaded, which is unusable once LiteLLM_EndUserTable grows + * (end-user rows are created automatically per distinct `user` seen in traffic). + * + * Example curl: + * ``` + * curl --location 'http://0.0.0.0:4000/customer/aliases?page=1&size=50&search=acme' --header 'Authorization: Bearer sk-1234' + * ``` + */ + get: operations["list_customer_aliases_customer_aliases_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/customer/block": { parameters: { query?: never; @@ -23290,6 +23320,29 @@ export interface components { [key: string]: unknown; }; }; + /** + * CustomerAliasesResponse + * @description Paginated, id-only customer listing used by UI filter dropdowns. + * + * Deliberately excludes budget/object-permission relations so a proxy with a + * large LiteLLM_EndUserTable can back a search-as-you-type control without + * materializing every row (see /customer/list for the full objects). + * + * Reports ``has_more`` rather than a total count on purpose: a total requires + * COUNT(*) over the whole match set on every keystroke, which is the exact + * cost this endpoint exists to avoid. Fetching one row beyond the page is + * enough to drive an infinite-scroll dropdown. + */ + CustomerAliasesResponse: { + /** Aliases */ + aliases: string[]; + /** Current Page */ + current_page: number; + /** Has More */ + has_more: boolean; + /** Size */ + size: number; + }; /** * CustomerResponse * @description Customer object returned by the /customer read+write endpoints. @@ -38395,6 +38448,42 @@ export interface operations { }; }; }; + list_customer_aliases_customer_aliases_get: { + parameters: { + query?: { + /** @description Page number */ + page?: number; + /** @description Page size */ + size?: number; + /** @description Case-insensitive partial match on the customer id */ + search?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CustomerAliasesResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; block_user_customer_block_post: { parameters: { query?: never; From 44b95bbfcbc72d23bd67dbb71b23112bd1295583 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 24 Jul 2026 16:21:46 -0700 Subject: [PATCH 2/5] fix(logs): scope the End User filter to the caller's teams and bound its scan The End User filter listed every row of LiteLLM_EndUserTable, which is both unscoped and the wrong source. Team admins and internal users can open the Logs page, and their log view is already restricted to their own requests plus the teams they administer, but the filter dropdown offered them every end user on the proxy. Team attribution only exists on spend logs, so /customer/aliases now reads LiteLLM_SpendLogs and applies the same scoping /spend/logs/ui does: a proxy admin sees the whole window, everyone else sees ("user" = caller OR team_id IN permitted_teams), reusing _get_permitted_team_ids_for_spend_logs so the two paths cannot drift. A caller with neither matches FALSE rather than falling through to unscoped, and a failed team lookup degrades to own-rows-only. Querying spend logs safely is the other half. start_date/end_date are now required, so the query always has the indexed startTime bound, and the inner scan is capped at MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS rows ordered by startTime DESC. DISTINCT therefore runs over a bounded row set instead of the whole table the way /global/all_end_users does. Also adds /customer/aliases to spend_tracking_routes. Without it RouteChecks rejects INTERNAL_USER and INTERNAL_USER_VIEW_ONLY before the handler runs, which would have made the scoping above dead code; a test pins the route to the same access tier as /spend/logs/ui. The dropdown now shows the end users present in the window the table is showing, so the filter list matches what it filters. formatLogsWindow is shared with the logs query so the two windows cannot diverge. --- litellm/constants.py | 3 + litellm/proxy/_types.py | 4 + .../customer_endpoints.py | 135 ++++++-- .../test_customer_endpoints.py | 302 ++++++++++++------ .../hooks/customers/useEndUserAliases.ts | 14 +- .../view_logs/RequestLogsFilters.test.tsx | 19 +- .../view_logs/RequestLogsFilters.tsx | 24 +- .../components/view_logs/RequestLogsPanel.tsx | 8 +- .../components/view_logs/RequestLogsTable.tsx | 6 +- .../components/view_logs/log_filter_logic.tsx | 21 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 22 +- 11 files changed, 397 insertions(+), 161 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index b9b9c0ba604..f0a7690096a 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1316,6 +1316,9 @@ STANDARD_CUSTOMER_ID_HEADERS = [ MAX_SPENDLOG_ROWS_TO_QUERY = int( os.getenv("MAX_SPENDLOG_ROWS_TO_QUERY", 1_000_000) ) # if spendLogs has more than 1M rows, do not query the DB +MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS = int( + os.getenv("MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS", 10_000) +) # hard cap on rows a UI filter dropdown may scan out of LiteLLM_SpendLogs DEFAULT_SOFT_BUDGET = float( os.getenv("DEFAULT_SOFT_BUDGET", 50.0) ) # by default all litellm proxy keys have a soft budget of 50.0 diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index ef160a68656..7575091be54 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -629,6 +629,10 @@ class LiteLLMRoutes(enum.Enum): "/spend/logs/v2", "/spend/logs/ui", "/spend/logs/session/ui", + # Reads end users out of spend logs, scoped to the caller's own rows and + # permitted teams exactly like /spend/logs/ui — it belongs to the same + # access tier, not to customer management. + "/customer/aliases", "/cost/estimate", ] diff --git a/litellm/proxy/management_endpoints/customer_endpoints.py b/litellm/proxy/management_endpoints/customer_endpoints.py index 0d1ea994ae9..8c0e67e7d0a 100644 --- a/litellm/proxy/management_endpoints/customer_endpoints.py +++ b/litellm/proxy/management_endpoints/customer_endpoints.py @@ -10,7 +10,8 @@ All /customer management endpoints """ #### END-USER/CUSTOMER MANAGEMENT #### -from datetime import datetime, timedelta +from collections.abc import MutableSequence +from datetime import datetime, timedelta, timezone from typing import Annotated, Any, List, Optional import fastapi @@ -19,6 +20,7 @@ from pydantic import BaseModel import litellm from litellm._logging import verbose_proxy_logger +from litellm.constants import MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS from litellm.litellm_core_utils.duration_parser import duration_in_seconds from litellm.proxy._types import * from litellm.proxy.auth.user_api_key_auth import user_api_key_auth @@ -27,7 +29,7 @@ from litellm.proxy.management_helpers.object_permission_utils import ( _set_object_permission, handle_update_object_permission_common, ) -from litellm.proxy.utils import handle_exception_on_proxy +from litellm.proxy.utils import PrismaClient, handle_exception_on_proxy from litellm.repositories.budget_repository import BudgetRepository from litellm.repositories.table_repositories import EndUserRepository from litellm.types.proxy.management_endpoints.common_daily_activity import ( @@ -786,17 +788,61 @@ async def list_end_user( raise handle_exception_on_proxy(e) -def _require_customer_read_access(user_api_key_dict: UserAPIKeyAuth) -> None: - if user_api_key_dict.user_role not in ( - LitellmUserRoles.PROXY_ADMIN, - LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, - ): +def _parse_spend_log_window_bound(value: str, param: str) -> datetime: + try: + return datetime.strptime(value.strip(), "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc) + except ValueError: raise HTTPException( - status_code=401, - detail={"error": "Admin-only endpoint. Your user role={}".format(user_api_key_dict.user_role)}, + status_code=400, + detail={"error": f"Invalid {param}: {value}. Expected 'YYYY-MM-DD HH:MM:SS'"}, ) +async def _build_end_user_scope_condition( + user_api_key_dict: UserAPIKeyAuth, + prisma_client: PrismaClient, + query_params: MutableSequence[Any], +) -> str | None: + """SQL predicate restricting end users to the logs this caller may read. + + Returns None when the caller is a proxy admin (no restriction). Mirrors the + scoping ``/spend/logs/ui`` applies, so the dropdown can never offer an + end user whose rows the caller could not open. + """ + from litellm.proxy.spend_tracking.spend_management_endpoints import ( + _get_permitted_team_ids_for_spend_logs, + _is_admin_view_safe, + ) + + if _is_admin_view_safe(user_api_key_dict=user_api_key_dict): + return None + + try: + permitted_team_ids = await _get_permitted_team_ids_for_spend_logs( + prisma_client=prisma_client, + user_api_key_dict=user_api_key_dict, + ) + except Exception: + permitted_team_ids = [] + + caller_user_id = user_api_key_dict.user_id + user_clause: tuple[str, ...] = () + if caller_user_id is not None: + query_params.append(caller_user_id) + user_clause = (f'"user" = ${len(query_params)}',) + + team_clause: tuple[str, ...] = () + if permitted_team_ids: + placeholders = ", ".join(f"${len(query_params) + i + 1}" for i in range(len(permitted_team_ids))) + query_params.extend(permitted_team_ids) + team_clause = (f"team_id IN ({placeholders})",) + + scope_parts = user_clause + team_clause + if not scope_parts: + return "FALSE" + return f"({' OR '.join(scope_parts)})" + + @router.get( "/customer/aliases", tags=["Customer Management"], @@ -805,6 +851,8 @@ def _require_customer_read_access(user_api_key_dict: UserAPIKeyAuth) -> None: ) async def list_customer_aliases( user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + start_date: Annotated[str, Query(description="Window start, 'YYYY-MM-DD HH:MM:SS' (UTC)")], + end_date: Annotated[str, Query(description="Window end, 'YYYY-MM-DD HH:MM:SS' (UTC)")], page: Annotated[int, Query(ge=1, description="Page number")] = 1, size: Annotated[int, Query(ge=1, le=100, description="Page size")] = 50, search: Annotated[ @@ -813,52 +861,75 @@ async def list_customer_aliases( ] = None, ) -> CustomerAliasesResponse: """ - [Admin-only] List customer ids with pagination and optional search. + List the end users seen in spend logs over a time window, for UI filter dropdowns. - Lightweight counterpart to `/customer/list`, for UI filter dropdowns. - `/customer/list` returns every customer with its budget and object-permission - relations eagerly loaded, which is unusable once LiteLLM_EndUserTable grows - (end-user rows are created automatically per distinct `user` seen in traffic). + Scoped like `/spend/logs/ui`: a proxy admin sees every end user in the window, + anyone else sees only end users from their own requests or from teams they + administer (or hold the `/spend/logs` permission on). + + Reads spend logs rather than LiteLLM_EndUserTable because only spend logs carry + the team attribution this scoping needs. The window is required and the inner + scan is capped at MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS rows, so the query + cannot degrade into a full-table scan the way `/global/all_end_users` does. Example curl: ``` - curl --location 'http://0.0.0.0:4000/customer/aliases?page=1&size=50&search=acme' \ + curl --location 'http://0.0.0.0:4000/customer/aliases?start_date=2026-07-23%2000:00:00&end_date=2026-07-24%2000:00:00&size=50&search=acme' \ --header 'Authorization: Bearer sk-1234' ``` """ try: from litellm.proxy.proxy_server import prisma_client - _require_customer_read_access(user_api_key_dict) - if prisma_client is None: raise HTTPException( status_code=400, detail={"error": CommonProxyErrors.db_not_connected_error.value}, ) - where_parts = ["user_id IS NOT NULL", "user_id != ''"] - query_params: List[Any] = [] + start_dt = _parse_spend_log_window_bound(start_date, "start_date") + end_dt = _parse_spend_log_window_bound(end_date, "end_date") + + query_params: List[Any] = [start_dt, end_dt] + where_parts = [ + "\"startTime\" >= ($1::timestamptz AT TIME ZONE 'UTC')", + "\"startTime\" <= ($2::timestamptz AT TIME ZONE 'UTC')", + "end_user IS NOT NULL", + "end_user != ''", + ] if search: # Escape LIKE metacharacters so a literal '_' or '%' matches itself. escaped = search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") query_params.append(f"%{escaped}%") - where_parts.append(f"user_id ILIKE ${len(query_params)} ESCAPE '\\'") + where_parts.append(f"end_user ILIKE ${len(query_params)} ESCAPE '\\'") - where_sql = " AND ".join(where_parts) - - # size + 1: one row beyond the page reveals has_more without a COUNT(*). - limit_params = query_params + [size + 1, (page - 1) * size] - aliases_sql = ( - f"SELECT user_id" - f' FROM "LiteLLM_EndUserTable"' - f" WHERE {where_sql}" - f" ORDER BY user_id ASC" - f" LIMIT ${len(limit_params) - 1} OFFSET ${len(limit_params)}" + scope_condition = await _build_end_user_scope_condition( + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + query_params=query_params, ) - rows = await prisma_client.db.query_raw(aliases_sql, *limit_params) - aliases: List[str] = [row["user_id"] for row in rows if row.get("user_id")] + if scope_condition is not None: + where_parts.append(scope_condition) + + # The inner LIMIT is the safety bound: it walks the startTime index newest + # first and stops, so DISTINCT never runs over an unbounded row set. + # size + 1: one row beyond the page reveals has_more without a COUNT(*). + params = query_params + [MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS, size + 1, (page - 1) * size] + scan_idx = len(params) - 2 + aliases_sql = ( + f"SELECT DISTINCT end_user FROM (" + f" SELECT end_user" + f' FROM "LiteLLM_SpendLogs"' + f" WHERE {' AND '.join(where_parts)}" + f' ORDER BY "startTime" DESC' + f" LIMIT ${scan_idx}" + f") recent" + f" ORDER BY end_user ASC" + f" LIMIT ${scan_idx + 1} OFFSET ${scan_idx + 2}" + ) + rows = await prisma_client.db.query_raw(aliases_sql, *params) + aliases: List[str] = [row["end_user"] for row in rows if row.get("end_user")] return CustomerAliasesResponse( aliases=aliases[:size], diff --git a/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py index 891b3454af8..5954d07bbb8 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from typing import List from unittest.mock import AsyncMock, MagicMock, patch @@ -9,6 +10,7 @@ from fastapi.testclient import TestClient from litellm.proxy._types import ( LiteLLM_EndUserTable, + LiteLLMRoutes, LitellmUserRoles, ProxyException, ) @@ -784,67 +786,188 @@ def test_char_delete_body(mock_prisma_client, mock_user_api_key_auth): } -def _mock_alias_rows(mock_prisma_client, user_ids: List[str]) -> AsyncMock: - query_raw = AsyncMock(return_value=[{"user_id": uid} for uid in user_ids]) +WINDOW = "start_date=2026-07-23+00%3A00%3A00&end_date=2026-07-24+00%3A00%3A00" + + +def _mock_alias_rows(mock_prisma_client, end_users: List[str]) -> AsyncMock: + query_raw = AsyncMock(return_value=[{"end_user": eu} for eu in end_users]) mock_prisma_client.db.query_raw = query_raw return query_raw -def test_customer_aliases_projects_only_user_id_and_never_loads_relations( - mock_prisma_client, mock_user_api_key_auth -): - """The whole point of this endpoint: no full rows, no eager relations. +def _as_role(role: LitellmUserRoles, user_id: str = "u1"): + """Override auth for one request; returns a context-manager-free setter/teardown pair.""" + original = app.dependency_overrides.copy() + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_id=user_id, user_role=role) + return original - /customer/list does find_many(include={budget, object_permission}) over the - entire table; this must stay a single-column, bounded query. - """ + +def test_customer_aliases_reads_spend_logs_not_the_end_user_table(mock_prisma_client, mock_user_api_key_auth): + """Team scoping only exists in spend logs, so that is the source of truth.""" query_raw = _mock_alias_rows(mock_prisma_client, ["a", "b"]) - response = client.get("/customer/aliases", headers={"Authorization": "Bearer k"}) + response = client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) assert response.status_code == 200 - assert response.json() == { - "aliases": ["a", "b"], - "current_page": 1, - "size": 50, - "has_more": False, - } - mock_prisma_client.db.litellm_endusertable.find_many.assert_not_called() + assert response.json() == {"aliases": ["a", "b"], "current_page": 1, "size": 50, "has_more": False} sql = query_raw.call_args.args[0] - assert "SELECT user_id" in sql - assert '"LiteLLM_EndUserTable"' in sql - assert "JOIN" not in sql.upper() - assert "COUNT(" not in sql.upper() + assert '"LiteLLM_SpendLogs"' in sql + assert "LiteLLM_EndUserTable" not in sql + mock_prisma_client.db.litellm_endusertable.find_many.assert_not_called() + + +def test_customer_aliases_caps_the_rows_it_scans(mock_prisma_client, mock_user_api_key_auth): + """The inner LIMIT is the crash guard: DISTINCT must never see an unbounded set.""" + from litellm.constants import MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS + + query_raw = _mock_alias_rows(mock_prisma_client, []) + + client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) + + sql = query_raw.call_args.args[0] + inner = sql[sql.index("FROM (") : sql.index(") recent")] + assert "LIMIT $3" in inner + assert query_raw.call_args.args[3] == MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS + assert 'ORDER BY "startTime" DESC' in inner + + +def test_customer_aliases_requires_a_time_window(mock_prisma_client, mock_user_api_key_auth): + """No window means no index bound, which is the unbounded scan we must not allow.""" + _mock_alias_rows(mock_prisma_client, []) + + assert client.get("/customer/aliases", headers={"Authorization": "Bearer k"}).status_code == 422 + assert ( + client.get( + "/customer/aliases?start_date=2026-07-23+00%3A00%3A00", headers={"Authorization": "Bearer k"} + ).status_code + == 422 + ) + + +def test_customer_aliases_bounds_the_window_on_the_indexed_start_time(mock_prisma_client, mock_user_api_key_auth): + query_raw = _mock_alias_rows(mock_prisma_client, []) + + client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) + + sql = query_raw.call_args.args[0] + assert "\"startTime\" >= ($1::timestamptz AT TIME ZONE 'UTC')" in sql + assert "\"startTime\" <= ($2::timestamptz AT TIME ZONE 'UTC')" in sql + assert query_raw.call_args.args[1] == datetime(2026, 7, 23, tzinfo=timezone.utc) + assert query_raw.call_args.args[2] == datetime(2026, 7, 24, tzinfo=timezone.utc) + + +def test_customer_aliases_rejects_a_malformed_window(mock_prisma_client, mock_user_api_key_auth): + _mock_alias_rows(mock_prisma_client, []) + + response = client.get( + f"/customer/aliases?start_date=yesterday&end_date=2026-07-24+00%3A00%3A00", + headers={"Authorization": "Bearer k"}, + ) + + assert response.status_code == 400 + + +def test_customer_aliases_applies_no_scope_for_a_proxy_admin(mock_prisma_client, mock_user_api_key_auth): + query_raw = _mock_alias_rows(mock_prisma_client, []) + + client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) + + sql = query_raw.call_args.args[0] + assert '"user" =' not in sql + assert "team_id IN" not in sql + + +@pytest.mark.parametrize("role", [LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY]) +def test_customer_aliases_scopes_a_team_admin_to_their_own_rows_and_teams(mock_prisma_client, role): + """A team admin must not see end users belonging to teams they cannot read.""" + query_raw = _mock_alias_rows(mock_prisma_client, ["cust-a"]) + original = _as_role(role, user_id="team-admin-1") + try: + with patch( + "litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs", + new=AsyncMock(return_value=["team-a", "team-b"]), + ): + response = client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) + finally: + app.dependency_overrides = original + + assert response.status_code == 200 + sql = query_raw.call_args.args[0] + assert '("user" = $3 OR team_id IN ($4, $5))' in sql + assert query_raw.call_args.args[3:6] == ("team-admin-1", "team-a", "team-b") + + +def test_customer_aliases_scopes_a_teamless_user_to_their_own_rows(mock_prisma_client): + query_raw = _mock_alias_rows(mock_prisma_client, []) + original = _as_role(LitellmUserRoles.INTERNAL_USER, user_id="solo") + try: + with patch( + "litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs", + new=AsyncMock(return_value=[]), + ): + response = client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) + finally: + app.dependency_overrides = original + + assert response.status_code == 200 + sql = query_raw.call_args.args[0] + assert '("user" = $3)' in sql + assert "team_id IN" not in sql + assert query_raw.call_args.args[3] == "solo" + + +def test_customer_aliases_returns_nothing_when_the_caller_owns_no_scope(mock_prisma_client): + """Unidentifiable caller must match no rows, never fall through to unscoped.""" + query_raw = _mock_alias_rows(mock_prisma_client, []) + original = _as_role(LitellmUserRoles.INTERNAL_USER, user_id=None) + try: + with patch( + "litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs", + new=AsyncMock(return_value=[]), + ): + response = client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) + finally: + app.dependency_overrides = original + + assert response.status_code == 200 + assert "FALSE" in query_raw.call_args.args[0] + + +def test_customer_aliases_scopes_when_permitted_team_lookup_fails(mock_prisma_client): + """A failed team lookup must degrade to own-rows-only, never to unscoped.""" + query_raw = _mock_alias_rows(mock_prisma_client, []) + original = _as_role(LitellmUserRoles.INTERNAL_USER, user_id="solo") + try: + with patch( + "litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs", + new=AsyncMock(side_effect=RuntimeError("db down")), + ): + response = client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) + finally: + app.dependency_overrides = original + + assert response.status_code == 200 + sql = query_raw.call_args.args[0] + assert '("user" = $3)' in sql + assert "team_id IN" not in sql def test_customer_aliases_fetches_one_extra_row_and_trims_it(mock_prisma_client, mock_user_api_key_auth): - """has_more is derived from a size+1 fetch; the sentinel row must not leak.""" query_raw = _mock_alias_rows(mock_prisma_client, [f"u{i}" for i in range(4)]) - response = client.get("/customer/aliases?size=3", headers={"Authorization": "Bearer k"}) + response = client.get(f"/customer/aliases?{WINDOW}&size=3", headers={"Authorization": "Bearer k"}) assert response.status_code == 200 - body = response.json() - assert body["aliases"] == ["u0", "u1", "u2"] - assert body["has_more"] is True - assert query_raw.call_args.args[1:] == (4, 0) - - -def test_customer_aliases_reports_no_more_pages_on_a_short_page(mock_prisma_client, mock_user_api_key_auth): - _mock_alias_rows(mock_prisma_client, ["u0", "u1"]) - - response = client.get("/customer/aliases?size=3", headers={"Authorization": "Bearer k"}) - - assert response.status_code == 200 - assert response.json()["has_more"] is False + assert response.json()["aliases"] == ["u0", "u1", "u2"] + assert response.json()["has_more"] is True + assert query_raw.call_args.args[4:] == (4, 0) def test_customer_aliases_reports_no_more_pages_on_an_exactly_full_page(mock_prisma_client, mock_user_api_key_auth): _mock_alias_rows(mock_prisma_client, ["u0", "u1", "u2"]) - response = client.get("/customer/aliases?size=3", headers={"Authorization": "Bearer k"}) + response = client.get(f"/customer/aliases?{WINDOW}&size=3", headers={"Authorization": "Bearer k"}) - assert response.status_code == 200 assert response.json()["aliases"] == ["u0", "u1", "u2"] assert response.json()["has_more"] is False @@ -852,90 +975,71 @@ def test_customer_aliases_reports_no_more_pages_on_an_exactly_full_page(mock_pri def test_customer_aliases_offsets_by_page(mock_prisma_client, mock_user_api_key_auth): query_raw = _mock_alias_rows(mock_prisma_client, []) - response = client.get("/customer/aliases?page=3&size=25", headers={"Authorization": "Bearer k"}) + response = client.get(f"/customer/aliases?{WINDOW}&page=3&size=25", headers={"Authorization": "Bearer k"}) - assert response.status_code == 200 assert response.json()["current_page"] == 3 - assert query_raw.call_args.args[1:] == (26, 50) - - -def test_customer_aliases_without_search_issues_no_like_filter(mock_prisma_client, mock_user_api_key_auth): - query_raw = _mock_alias_rows(mock_prisma_client, []) - - client.get("/customer/aliases", headers={"Authorization": "Bearer k"}) - - sql = query_raw.call_args.args[0] - assert "ILIKE" not in sql.upper() - assert query_raw.call_args.args[1:] == (51, 0) + assert query_raw.call_args.args[4:] == (26, 50) def test_customer_aliases_search_escapes_like_metacharacters(mock_prisma_client, mock_user_api_key_auth): - """End-user ids routinely contain '_'; an unescaped one is a wildcard. - - Without ESCAPE, searching 'device_id' also matches 'deviceXid'. - """ + """End-user ids routinely contain '_'; unescaped it is a wildcard.""" query_raw = _mock_alias_rows(mock_prisma_client, []) - client.get("/customer/aliases?search=device_id%25", headers={"Authorization": "Bearer k"}) + client.get(f"/customer/aliases?{WINDOW}&search=device_id%25", headers={"Authorization": "Bearer k"}) - sql = query_raw.call_args.args[0] - assert "ILIKE $1 ESCAPE" in sql - assert query_raw.call_args.args[1] == r"%device\_id\%%" - assert query_raw.call_args.args[2:] == (51, 0) + assert "end_user ILIKE $3 ESCAPE" in query_raw.call_args.args[0] + assert query_raw.call_args.args[3] == r"%device\_id\%%" -def test_customer_aliases_search_placeholder_precedes_limit_and_offset(mock_prisma_client, mock_user_api_key_auth): +def test_customer_aliases_search_placeholder_precedes_scan_limit_and_offset(mock_prisma_client, mock_user_api_key_auth): query_raw = _mock_alias_rows(mock_prisma_client, []) - client.get("/customer/aliases?search=acme&size=10", headers={"Authorization": "Bearer k"}) + client.get(f"/customer/aliases?{WINDOW}&search=acme&size=10", headers={"Authorization": "Bearer k"}) sql = query_raw.call_args.args[0] - assert "LIMIT $2 OFFSET $3" in sql - assert query_raw.call_args.args[1:] == ("%acme%", 11, 0) + assert "LIMIT $4" in sql + assert "LIMIT $5 OFFSET $6" in sql + assert query_raw.call_args.args[3] == "%acme%" + assert query_raw.call_args.args[5:] == (11, 0) + + +def test_customer_aliases_caps_page_size(mock_prisma_client, mock_user_api_key_auth): + _mock_alias_rows(mock_prisma_client, []) + + response = client.get(f"/customer/aliases?{WINDOW}&size=100000", headers={"Authorization": "Bearer k"}) + + assert response.status_code == 422 @pytest.mark.parametrize( "role", [ + LitellmUserRoles.PROXY_ADMIN, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, - LitellmUserRoles.TEAM, - LitellmUserRoles.CUSTOMER, ], ) -def test_customer_aliases_rejects_non_admin_roles(mock_prisma_client, role): - """Mirrors /customer/list: this exposes every customer id on the proxy.""" - _mock_alias_rows(mock_prisma_client, ["secret-customer"]) - original_overrides = app.dependency_overrides.copy() - app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_id="u", user_role=role) - try: - response = client.get("/customer/aliases", headers={"Authorization": "Bearer k"}) - finally: - app.dependency_overrides = original_overrides +def test_customer_aliases_is_reachable_by_every_role_that_can_open_the_logs_page(role): + """Route-level auth gate, which the dependency_overrides in the other tests bypass. - assert response.status_code == 401 - assert "secret-customer" not in response.text + Handler-side team scoping is dead code if RouteChecks rejects the role first, + so pin that /customer/aliases travels in the same access tier as /spend/logs/ui. + """ + from litellm.proxy.auth.route_checks import RouteChecks + for allowed in ( + LiteLLMRoutes.internal_user_routes.value, + LiteLLMRoutes.internal_user_view_only_routes.value, + ): + assert ("/spend/logs/ui" in allowed) == ("/customer/aliases" in allowed) -def test_customer_aliases_allows_admin_viewer(mock_prisma_client): - _mock_alias_rows(mock_prisma_client, ["a"]) - original_overrides = app.dependency_overrides.copy() - app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( - user_id="u", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY - ) - try: - response = client.get("/customer/aliases", headers={"Authorization": "Bearer k"}) - finally: - app.dependency_overrides = original_overrides - - assert response.status_code == 200 - assert response.json()["aliases"] == ["a"] - - -def test_customer_aliases_caps_page_size(mock_prisma_client, mock_user_api_key_auth): - """An unbounded size would reintroduce the very problem this endpoint fixes.""" - _mock_alias_rows(mock_prisma_client, []) - - response = client.get("/customer/aliases?size=100000", headers={"Authorization": "Bearer k"}) - - assert response.status_code == 422 + if role in (LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY): + allowed_routes = ( + LiteLLMRoutes.internal_user_routes.value + if role == LitellmUserRoles.INTERNAL_USER + else LiteLLMRoutes.internal_user_view_only_routes.value + ) + assert RouteChecks.check_route_access(route="/customer/aliases", allowed_routes=allowed_routes) + else: + assert "/customer/aliases" in LiteLLMRoutes.admin_viewer_routes.value diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useEndUserAliases.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useEndUserAliases.ts index b28c2328607..2625361231f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useEndUserAliases.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useEndUserAliases.ts @@ -1,18 +1,22 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { $api } from "@/lib/http/api"; import type { components } from "@/lib/http/schema"; -import { all_admin_roles } from "@/utils/roles"; type EndUserAliasesPage = components["schemas"]["CustomerAliasesResponse"]; -export const useInfiniteEndUserAliases = (size: number = 50, search?: string) => { - const { accessToken, userRole } = useAuthorized(); - const query = { size, ...(search !== undefined && search !== "" ? { search } : {}) }; +export interface EndUserAliasesWindow { + start_date: string; + end_date: string; +} + +export const useInfiniteEndUserAliases = (window: EndUserAliasesWindow, size: number = 50, search?: string) => { + const { accessToken } = useAuthorized(); + const query = { ...window, size, ...(search !== undefined && search !== "" ? { search } : {}) }; const options = { pageParamName: "page", initialPageParam: 1, getNextPageParam: (lastPage: EndUserAliasesPage) => (lastPage.has_more ? lastPage.current_page + 1 : undefined), - enabled: Boolean(accessToken) && all_admin_roles.includes(userRole ?? ""), + enabled: Boolean(accessToken), }; return $api.useInfiniteQuery("get", "/customer/aliases", { params: { query } }, options); }; diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx index 896c3767203..acfd7c63c64 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx @@ -30,9 +30,13 @@ const emptyInfiniteQuery = { isLoading: false, }; +const LOGS_WINDOW = { start_date: "2026-07-23 00:00:00", end_date: "2026-07-24 00:00:00" }; + function renderFilters(filters: Record = {}) { const set = vi.fn(); - renderWithProviders( filters[id]} set={set} teams={[]} />); + renderWithProviders( + filters[id]} set={set} teams={[]} logsWindow={LOGS_WINDOW} />, + ); return { set }; } @@ -91,11 +95,11 @@ describe("RequestLogsFilters", () => { expect(useInfiniteModelInfo).toHaveBeenCalledWith(50, undefined); }); - it("asks the server for a bounded page of end users instead of the whole customer table", async () => { + it("asks the server for a bounded page of end users scoped to the visible time window", async () => { renderFilters(); await waitFor(() => expect(useInfiniteEndUserAliases).toHaveBeenCalled()); - expect(useInfiniteEndUserAliases).toHaveBeenCalledWith(50, undefined); + expect(useInfiniteEndUserAliases).toHaveBeenCalledWith(LOGS_WINDOW, 50, undefined); }); it("pushes the End User query to the server rather than filtering a preloaded list", async () => { @@ -106,7 +110,7 @@ describe("RequestLogsFilters", () => { await user.click(input); await user.type(input, "acme"); - await waitFor(() => expect(useInfiniteEndUserAliases).toHaveBeenCalledWith(50, "acme")); + await waitFor(() => expect(useInfiniteEndUserAliases).toHaveBeenCalledWith(LOGS_WINDOW, 50, "acme")); }); it("renders only the end users the current page returned", async () => { @@ -143,4 +147,11 @@ describe("RequestLogsFilters", () => { await waitFor(() => expect(fetchNextPage).toHaveBeenCalled()); }); + + it("scopes the End User lookup to the window the logs table is showing", async () => { + const otherWindow = { start_date: "2026-01-01 00:00:00", end_date: "2026-01-02 00:00:00" }; + renderWithProviders( undefined} set={vi.fn()} teams={[]} logsWindow={otherWindow} />); + + await waitFor(() => expect(useInfiniteEndUserAliases).toHaveBeenCalledWith(otherWindow, 50, undefined)); + }); }); diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx index e79481979b3..054caf46943 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx @@ -21,7 +21,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import type { Team } from "../key_team_helpers/key_list"; import { ERROR_CODE_OPTIONS } from "./constants"; -import { LOG_FILTER_IDS } from "./log_filter_logic"; +import { LOG_FILTER_IDS, type LogsWindow } from "./log_filter_logic"; const ALL_VALUE = "all"; const PAGE_SIZE = 50; @@ -144,9 +144,18 @@ function ModelFilterField({ value, onChange }: { value: string; onChange: (value ); } -function EndUserFilterField({ value, onChange }: { value: string; onChange: (value: string | undefined) => void }) { +function EndUserFilterField({ + value, + onChange, + logsWindow, +}: { + value: string; + onChange: (value: string | undefined) => void; + logsWindow: LogsWindow; +}) { const [search, setSearch] = useState(""); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteEndUserAliases( + logsWindow, PAGE_SIZE, emptyToUndefined(search), ); @@ -174,7 +183,7 @@ function EndUserFilterField({ value, onChange }: { value: string; onChange: (val isLoading={isLoading} isFetchingNextPage={isFetchingNextPage} placeholder="Search an end user" - emptyText="No end users found" + emptyText="No end users in this time range" /> ); @@ -233,9 +242,10 @@ interface RequestLogsFiltersProps { get: (columnId: string) => unknown; set: (columnId: string, value: unknown) => void; teams: Team[]; + logsWindow: LogsWindow; } -export function RequestLogsFilters({ get, set, teams }: RequestLogsFiltersProps) { +export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsFiltersProps) { const valueOf = (id: string): string => asString(get(id)); const setter = (id: string) => (next: string | undefined) => set(id, next); @@ -269,7 +279,11 @@ export function RequestLogsFilters({ get, set, teams }: RequestLogsFiltersProps) teamId={valueOf(LOG_FILTER_IDS.TEAM_ID)} /> - + diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx index 5669b67e00a..cc5e6e899f5 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx @@ -12,7 +12,7 @@ import { keyInfoV1Call } from "../networking"; import KeyInfoView from "../templates/key_info_view"; import type { LogEntry } from "./columns"; import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants"; -import { DEFAULT_LOGS_SORTING, LOG_FILTER_IDS, useLogFilterLogic } from "./log_filter_logic"; +import { DEFAULT_LOGS_SORTING, formatLogsWindow, LOG_FILTER_IDS, useLogFilterLogic } from "./log_filter_logic"; import { LogDetailsDrawer } from "./LogDetailsDrawer"; import { LiveTailBanner, LogsTableToolbar } from "./LogsTableToolbar"; import { RequestLogsTable } from "./RequestLogsTable"; @@ -76,6 +76,11 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, sorting, }); + const logsWindow = useMemo( + () => formatLogsWindow(startTime, endTime, isCustomDate), + [startTime, endTime, isCustomDate], + ); + const keyInfoQueryOptions: UseQueryOptions = { queryKey: ["requestLogsKeyInfo", selectedKeyIdInfoView, accessToken], queryFn: async () => { @@ -232,6 +237,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, onKeyHashClick={handleKeyHashClick} onSessionClick={handleSessionClick} teams={allTeams ?? []} + logsWindow={logsWindow} toolbarChildren={ void; onSessionClick: (sessionId: string) => void; teams: Team[]; + logsWindow: LogsWindow; toolbarChildren?: ReactNode; } @@ -67,6 +68,7 @@ export function RequestLogsTable({ onKeyHashClick, onSessionClick, teams, + logsWindow, toolbarChildren, }: RequestLogsTableProps) { const [filtersOpen, setFiltersOpen] = useState(false); @@ -120,7 +122,7 @@ export function RequestLogsTable({ title="Filters" description="Narrow down request logs" > - {({ get, set }) => } + {({ get, set }) => } )} diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx index ae229056fa3..9b3e2334a35 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx @@ -44,6 +44,18 @@ export const LOG_FILTER_LABELS: Record = { [LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL]: "Public model / search tool", }; +export interface LogsWindow { + start_date: string; + end_date: string; +} + +export const formatLogsWindow = (startTime: string, endTime: string, isCustomDate: boolean): LogsWindow => ({ + start_date: moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss"), + end_date: isCustomDate + ? moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss") + : moment().utc().format("YYYY-MM-DD HH:mm:ss"), +}); + export const LIVE_TAIL_INTERVAL_MS = 15000; export const getLiveTailRefetchInterval = (isLiveTail: boolean, pageIndex: number): number | false => @@ -119,17 +131,14 @@ export function useLogFilterLogic({ }; } - const formattedStartTime = moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss"); - const formattedEndTime = isCustomDate - ? moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss") - : moment().utc().format("YYYY-MM-DD HH:mm:ss"); + const window = formatLogsWindow(startTime, endTime, isCustomDate); const userIdFilter = getFilterValue(columnFilters, LOG_FILTER_IDS.USER_ID); return await uiSpendLogsCall({ accessToken, - start_date: formattedStartTime, - end_date: formattedEndTime, + start_date: window.start_date, + end_date: window.end_date, page: pagination.pageIndex + 1, page_size: pageSize, params: { diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index ee2f549d3f2..a8b5dd5a28a 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -2781,16 +2781,20 @@ export interface paths { }; /** * List Customer Aliases - * @description [Admin-only] List customer ids with pagination and optional search. + * @description List the end users seen in spend logs over a time window, for UI filter dropdowns. * - * Lightweight counterpart to `/customer/list`, for UI filter dropdowns. - * `/customer/list` returns every customer with its budget and object-permission - * relations eagerly loaded, which is unusable once LiteLLM_EndUserTable grows - * (end-user rows are created automatically per distinct `user` seen in traffic). + * Scoped like `/spend/logs/ui`: a proxy admin sees every end user in the window, + * anyone else sees only end users from their own requests or from teams they + * administer (or hold the `/spend/logs` permission on). + * + * Reads spend logs rather than LiteLLM_EndUserTable because only spend logs carry + * the team attribution this scoping needs. The window is required and the inner + * scan is capped at MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS rows, so the query + * cannot degrade into a full-table scan the way `/global/all_end_users` does. * * Example curl: * ``` - * curl --location 'http://0.0.0.0:4000/customer/aliases?page=1&size=50&search=acme' --header 'Authorization: Bearer sk-1234' + * curl --location 'http://0.0.0.0:4000/customer/aliases?start_date=2026-07-23%2000:00:00&end_date=2026-07-24%2000:00:00&size=50&search=acme' --header 'Authorization: Bearer sk-1234' * ``` */ get: operations["list_customer_aliases_customer_aliases_get"]; @@ -38450,7 +38454,11 @@ export interface operations { }; list_customer_aliases_customer_aliases_get: { parameters: { - query?: { + query: { + /** @description Window start, 'YYYY-MM-DD HH:MM:SS' (UTC) */ + start_date: string; + /** @description Window end, 'YYYY-MM-DD HH:MM:SS' (UTC) */ + end_date: string; /** @description Page number */ page?: number; /** @description Page size */ From 44f571a8aaa9b5e52102dfff49f03618ece347a9 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 24 Jul 2026 17:28:17 -0700 Subject: [PATCH 3/5] fix(logs): keep the End User filter window in step with the logs table Two issues Greptile raised on the filter window and the capped scan. A preset date range ends at "now", which the logs query re-reads on every fetch, so live tail keeps moving the table's end bound. The filter window was memoized on the date controls alone, so it pinned whichever "now" it was first built with: an end user that started sending traffic afterwards showed up in the table but stayed missing from the dropdown until something remounted it. formatLogsWindow now takes the preset end bound as an argument, and getLogsWindowEndBound derives it from the logs query's last fetch, rounded up to the next minute. Rounding up rather than down means the filter window never trails the table; bucketing means the query key holds steady between ticks instead of churning once per render. The panel reads it from logsQuery.dataUpdatedAt so it advances exactly when the table refreshes, falling back to the stored end time before the first fetch. Deriving it from Date.now() during render is what the purity rule forbids. The capped inner scan ordered by startTime alone, so rows sharing a timestamp could be cut differently between two requests and successive OFFSET pages would disagree about the set they were paging through. request_id now breaks the tie, which the (startTime, request_id) index already covers. Drift from rows genuinely arriving inside the window between page fetches is left alone. Removing it means keyset pagination over the distinct set, which cannot keep the inner row cap, and that cap is what stops this query from degrading into a full scan of LiteLLM_SpendLogs. --- .../customer_endpoints.py | 5 +- .../test_customer_endpoints.py | 11 ++++ .../components/view_logs/RequestLogsPanel.tsx | 15 ++++- .../view_logs/log_filter_logic.test.tsx | 61 +++++++++++++++++++ .../components/view_logs/log_filter_logic.tsx | 23 ++++++- 5 files changed, 109 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/management_endpoints/customer_endpoints.py b/litellm/proxy/management_endpoints/customer_endpoints.py index 8c0e67e7d0a..cc6bacbd21e 100644 --- a/litellm/proxy/management_endpoints/customer_endpoints.py +++ b/litellm/proxy/management_endpoints/customer_endpoints.py @@ -914,6 +914,9 @@ async def list_customer_aliases( # The inner LIMIT is the safety bound: it walks the startTime index newest # first and stops, so DISTINCT never runs over an unbounded row set. + # request_id breaks startTime ties so the cut-off row is deterministic and + # successive OFFSET pages agree on the set they are paging through; the + # (startTime, request_id) index means the tiebreaker costs nothing. # size + 1: one row beyond the page reveals has_more without a COUNT(*). params = query_params + [MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS, size + 1, (page - 1) * size] scan_idx = len(params) - 2 @@ -922,7 +925,7 @@ async def list_customer_aliases( f" SELECT end_user" f' FROM "LiteLLM_SpendLogs"' f" WHERE {' AND '.join(where_parts)}" - f' ORDER BY "startTime" DESC' + f' ORDER BY "startTime" DESC, request_id DESC' f" LIMIT ${scan_idx}" f") recent" f" ORDER BY end_user ASC" diff --git a/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py index 5954d07bbb8..db91984f63d 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py @@ -831,6 +831,17 @@ def test_customer_aliases_caps_the_rows_it_scans(mock_prisma_client, mock_user_a assert 'ORDER BY "startTime" DESC' in inner +def test_customer_aliases_breaks_start_time_ties_deterministically(mock_prisma_client, mock_user_api_key_auth): + """Without a unique tiebreaker the capped scan can cut differently per request, + so OFFSET page 2 would page through a different set than page 1 did.""" + query_raw = _mock_alias_rows(mock_prisma_client, []) + + client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) + + sql = query_raw.call_args.args[0] + assert 'ORDER BY "startTime" DESC, request_id DESC' in sql + + def test_customer_aliases_requires_a_time_window(mock_prisma_client, mock_user_api_key_auth): """No window means no index bound, which is the unbounded scan we must not allow.""" _mock_alias_rows(mock_prisma_client, []) diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx index cc5e6e899f5..b3b1e8c0640 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx @@ -12,7 +12,13 @@ import { keyInfoV1Call } from "../networking"; import KeyInfoView from "../templates/key_info_view"; import type { LogEntry } from "./columns"; import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants"; -import { DEFAULT_LOGS_SORTING, formatLogsWindow, LOG_FILTER_IDS, useLogFilterLogic } from "./log_filter_logic"; +import { + DEFAULT_LOGS_SORTING, + formatLogsWindow, + getLogsWindowEndBound, + LOG_FILTER_IDS, + useLogFilterLogic, +} from "./log_filter_logic"; import { LogDetailsDrawer } from "./LogDetailsDrawer"; import { LiveTailBanner, LogsTableToolbar } from "./LogsTableToolbar"; import { RequestLogsTable } from "./RequestLogsTable"; @@ -76,9 +82,12 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, sorting, }); + // Follow the table's own last fetch so a live-tail refresh carries the filter + // window with it; before the first fetch, fall back to the stored end time. + const windowEndBound = getLogsWindowEndBound(logsQuery.dataUpdatedAt || Date.parse(endTime)); const logsWindow = useMemo( - () => formatLogsWindow(startTime, endTime, isCustomDate), - [startTime, endTime, isCustomDate], + () => formatLogsWindow(startTime, endTime, isCustomDate, windowEndBound), + [startTime, endTime, isCustomDate, windowEndBound], ); const keyInfoQueryOptions: UseQueryOptions = { diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx index 9f8f2fbe542..080bf6b380a 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx @@ -6,10 +6,13 @@ import React, { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_LOGS_SORTING, + formatLogsWindow, getFilterValue, getLiveTailRefetchInterval, + getLogsWindowEndBound, LIVE_TAIL_INTERVAL_MS, LOG_FILTER_IDS, + LOGS_WINDOW_TICK_MS, useLogFilterLogic, type PaginatedResponse, } from "./log_filter_logic"; @@ -233,3 +236,61 @@ describe("getLiveTailRefetchInterval", () => { expect(getLiveTailRefetchInterval(true, 1)).toBe(false); }); }); + +describe("formatLogsWindow", () => { + it("pins the end bound for a custom range", () => { + const w = formatLogsWindow("2026-07-23T00:00", "2026-07-24T06:00", true); + + expect(w.start_date).toBe(moment("2026-07-23T00:00").utc().format("YYYY-MM-DD HH:mm:ss")); + expect(w.end_date).toBe(moment("2026-07-24T06:00").utc().format("YYYY-MM-DD HH:mm:ss")); + }); + + it("ends a preset range at now, not at the stored end time", () => { + const w = formatLogsWindow("2026-07-23T00:00", "1999-01-01T00:00", false); + + expect(w.end_date > "2020-01-01 00:00:00").toBe(true); + }); +}); + +describe("getLogsWindowEndBound", () => { + const BUCKET_START = 16666 * LOGS_WINDOW_TICK_MS; + + it("holds steady inside a bucket so a memoized window does not refetch per render", () => { + expect(getLogsWindowEndBound(BUCKET_START)).toBe(getLogsWindowEndBound(BUCKET_START + LOGS_WINDOW_TICK_MS - 1)); + }); + + it("advances once the bucket rolls over so a preset window follows the table", () => { + expect(getLogsWindowEndBound(BUCKET_START + LOGS_WINDOW_TICK_MS)).toBe( + getLogsWindowEndBound(BUCKET_START) + LOGS_WINDOW_TICK_MS, + ); + }); + + it("never trails the fetch it was derived from", () => { + // Trailing is the live-tail bug: the table shows rows the filter window excludes. + for (const offset of [0, 1, LOGS_WINDOW_TICK_MS - 1]) { + expect(getLogsWindowEndBound(BUCKET_START + offset)).toBeGreaterThan(BUCKET_START + offset); + } + }); + + it("advances at least once per live-tail refetch interval", () => { + expect(LOGS_WINDOW_TICK_MS).toBeLessThanOrEqual(4 * LIVE_TAIL_INTERVAL_MS); + }); +}); + +describe("formatLogsWindow preset end bound", () => { + it("uses the supplied bound for a preset range so callers can memoize it", () => { + const bound = Date.UTC(2026, 6, 24, 12, 0, 0); + + expect(formatLogsWindow("2026-07-23T00:00", "1999-01-01T00:00", false, bound).end_date).toBe( + moment(bound).utc().format("YYYY-MM-DD HH:mm:ss"), + ); + }); + + it("ignores the supplied bound for a custom range", () => { + const bound = Date.UTC(2026, 6, 24, 12, 0, 0); + + expect(formatLogsWindow("2026-07-23T00:00", "2026-07-24T06:00", true, bound).end_date).toBe( + moment("2026-07-24T06:00").utc().format("YYYY-MM-DD HH:mm:ss"), + ); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx index 9b3e2334a35..8244066cadb 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx @@ -49,13 +49,32 @@ export interface LogsWindow { end_date: string; } -export const formatLogsWindow = (startTime: string, endTime: string, isCustomDate: boolean): LogsWindow => ({ +export const formatLogsWindow = ( + startTime: string, + endTime: string, + isCustomDate: boolean, + presetEndMs: number = Date.now(), +): LogsWindow => ({ start_date: moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss"), end_date: isCustomDate ? moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss") - : moment().utc().format("YYYY-MM-DD HH:mm:ss"), + : moment(presetEndMs).utc().format("YYYY-MM-DD HH:mm:ss"), }); +export const LOGS_WINDOW_TICK_MS = 60000; + +/** + * Stable end bound for anything that memoizes a preset (non-custom) window. + * + * The logs query re-reads "now" on every fetch, so a live-tail refresh keeps moving + * its end bound. A memoized window needs to follow, or it pins a bound the table has + * already passed and stops offering end users the table is showing. Rounding the + * last-fetch time UP to the next bucket keeps the value stable between ticks (so the + * query key does not churn per render) while never trailing behind the table. + */ +export const getLogsWindowEndBound = (lastFetchedAtMs: number): number => + (Math.floor(lastFetchedAtMs / LOGS_WINDOW_TICK_MS) + 1) * LOGS_WINDOW_TICK_MS; + export const LIVE_TAIL_INTERVAL_MS = 15000; export const getLiveTailRefetchInterval = (isLiveTail: boolean, pageIndex: number): number | false => From fc278bbf9f6dd752a5b444e3a7765e175dd27e76 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 24 Jul 2026 17:39:29 -0700 Subject: [PATCH 4/5] refactor(logs): match ui_view_spend_logs' team-scope clause shape The scope predicate expanded permitted team ids into an IN list with one placeholder each, copied from /key/aliases. ui_view_spend_logs, which owns the same page and the same scoping rules, builds ("user" = $X OR team_id = ANY($Y::text[])) instead: a single array parameter whatever the team count, and no placeholder arithmetic to keep in step with the rest of the query. Same semantics, but the two clauses now read identically, so a future change to how spend logs are scoped is harder to apply to one and miss in the other. --- .../proxy/management_endpoints/customer_endpoints.py | 7 ++++--- .../management_endpoints/test_customer_endpoints.py | 12 +++++++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/management_endpoints/customer_endpoints.py b/litellm/proxy/management_endpoints/customer_endpoints.py index cc6bacbd21e..f32b0d0c3fc 100644 --- a/litellm/proxy/management_endpoints/customer_endpoints.py +++ b/litellm/proxy/management_endpoints/customer_endpoints.py @@ -833,9 +833,10 @@ async def _build_end_user_scope_condition( team_clause: tuple[str, ...] = () if permitted_team_ids: - placeholders = ", ".join(f"${len(query_params) + i + 1}" for i in range(len(permitted_team_ids))) - query_params.extend(permitted_team_ids) - team_clause = (f"team_id IN ({placeholders})",) + # = ANY(::text[]) rather than an expanded IN list, matching the clause + # ui_view_spend_logs builds: one parameter whatever the team count. + query_params.append(permitted_team_ids) + team_clause = (f"team_id = ANY(${len(query_params)}::text[])",) scope_parts = user_clause + team_clause if not scope_parts: diff --git a/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py index db91984f63d..b3768419a37 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py @@ -885,7 +885,7 @@ def test_customer_aliases_applies_no_scope_for_a_proxy_admin(mock_prisma_client, sql = query_raw.call_args.args[0] assert '"user" =' not in sql - assert "team_id IN" not in sql + assert "team_id" not in sql @pytest.mark.parametrize("role", [LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY]) @@ -904,8 +904,10 @@ def test_customer_aliases_scopes_a_team_admin_to_their_own_rows_and_teams(mock_p assert response.status_code == 200 sql = query_raw.call_args.args[0] - assert '("user" = $3 OR team_id IN ($4, $5))' in sql - assert query_raw.call_args.args[3:6] == ("team-admin-1", "team-a", "team-b") + # Same clause shape ui_view_spend_logs builds, so the two cannot diverge. + assert '("user" = $3 OR team_id = ANY($4::text[]))' in sql + assert query_raw.call_args.args[3] == "team-admin-1" + assert query_raw.call_args.args[4] == ["team-a", "team-b"] def test_customer_aliases_scopes_a_teamless_user_to_their_own_rows(mock_prisma_client): @@ -923,7 +925,7 @@ def test_customer_aliases_scopes_a_teamless_user_to_their_own_rows(mock_prisma_c assert response.status_code == 200 sql = query_raw.call_args.args[0] assert '("user" = $3)' in sql - assert "team_id IN" not in sql + assert "team_id" not in sql assert query_raw.call_args.args[3] == "solo" @@ -960,7 +962,7 @@ def test_customer_aliases_scopes_when_permitted_team_lookup_fails(mock_prisma_cl assert response.status_code == 200 sql = query_raw.call_args.args[0] assert '("user" = $3)' in sql - assert "team_id IN" not in sql + assert "team_id" not in sql def test_customer_aliases_fetches_one_extra_row_and_trims_it(mock_prisma_client, mock_user_api_key_auth): From 48e77738ca004f5f64baef1f8b15c6a41033ea49 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 24 Jul 2026 18:24:13 -0700 Subject: [PATCH 5/5] refactor(logs): make the end-user filter scan cap a fixed constant The cap was an env-tunable knob in constants.py. Nothing needs to tune it: it exists so DISTINCT cannot run over an unbounded row set, and picking a value is a correctness decision, not deployment configuration. An env var also makes the bound unverifiable, since the same code can behave very differently between two proxies. It is now a plain constant next to its only caller, mirroring how SPEND_LOGS_PAGINATION_COUNT_CAP sits beside ui_view_spend_logs, and it takes that constant's value: both reads of LiteLLM_SpendLogs now stop at the same depth. constants.py goes back to matching staging exactly. The existing test only asserted the parameter equalled the constant, which is tautological; raising the constant to a billion kept it green while removing the bound. A second test pins the value against the logs page's cap, so an arbitrary change to either one fails. --- litellm/constants.py | 3 --- .../customer_endpoints.py | 10 +++++++--- .../test_customer_endpoints.py | 20 +++++++++++++++++-- ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 +- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index f0a7690096a..b9b9c0ba604 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1316,9 +1316,6 @@ STANDARD_CUSTOMER_ID_HEADERS = [ MAX_SPENDLOG_ROWS_TO_QUERY = int( os.getenv("MAX_SPENDLOG_ROWS_TO_QUERY", 1_000_000) ) # if spendLogs has more than 1M rows, do not query the DB -MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS = int( - os.getenv("MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS", 10_000) -) # hard cap on rows a UI filter dropdown may scan out of LiteLLM_SpendLogs DEFAULT_SOFT_BUDGET = float( os.getenv("DEFAULT_SOFT_BUDGET", 50.0) ) # by default all litellm proxy keys have a soft budget of 50.0 diff --git a/litellm/proxy/management_endpoints/customer_endpoints.py b/litellm/proxy/management_endpoints/customer_endpoints.py index f32b0d0c3fc..a46481d5bb7 100644 --- a/litellm/proxy/management_endpoints/customer_endpoints.py +++ b/litellm/proxy/management_endpoints/customer_endpoints.py @@ -20,7 +20,6 @@ from pydantic import BaseModel import litellm from litellm._logging import verbose_proxy_logger -from litellm.constants import MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS from litellm.litellm_core_utils.duration_parser import duration_in_seconds from litellm.proxy._types import * from litellm.proxy.auth.user_api_key_auth import user_api_key_auth @@ -45,6 +44,11 @@ from litellm.types.proxy.management_endpoints.customer_endpoints import ( router = APIRouter() +# Rows the end-user filter query may read out of LiteLLM_SpendLogs before DISTINCT. +# Matches SPEND_LOGS_PAGINATION_COUNT_CAP, the equivalent bound ui_view_spend_logs +# puts on its count query, so both reads of the same table stop at the same depth. +SPEND_LOGS_FILTER_SCAN_CAP = 10000 + def _to_customer_response(record: BaseModel) -> CustomerResponse: """Validate a raw end-user DB row into the typed customer response. @@ -870,7 +874,7 @@ async def list_customer_aliases( Reads spend logs rather than LiteLLM_EndUserTable because only spend logs carry the team attribution this scoping needs. The window is required and the inner - scan is capped at MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS rows, so the query + scan is capped at SPEND_LOGS_FILTER_SCAN_CAP rows, so the query cannot degrade into a full-table scan the way `/global/all_end_users` does. Example curl: @@ -919,7 +923,7 @@ async def list_customer_aliases( # successive OFFSET pages agree on the set they are paging through; the # (startTime, request_id) index means the tiebreaker costs nothing. # size + 1: one row beyond the page reveals has_more without a COUNT(*). - params = query_params + [MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS, size + 1, (page - 1) * size] + params = query_params + [SPEND_LOGS_FILTER_SCAN_CAP, size + 1, (page - 1) * size] scan_idx = len(params) - 2 aliases_sql = ( f"SELECT DISTINCT end_user FROM (" diff --git a/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py index b3768419a37..98e93eea5f9 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py @@ -818,7 +818,7 @@ def test_customer_aliases_reads_spend_logs_not_the_end_user_table(mock_prisma_cl def test_customer_aliases_caps_the_rows_it_scans(mock_prisma_client, mock_user_api_key_auth): """The inner LIMIT is the crash guard: DISTINCT must never see an unbounded set.""" - from litellm.constants import MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS + from litellm.proxy.management_endpoints.customer_endpoints import SPEND_LOGS_FILTER_SCAN_CAP query_raw = _mock_alias_rows(mock_prisma_client, []) @@ -827,10 +827,26 @@ def test_customer_aliases_caps_the_rows_it_scans(mock_prisma_client, mock_user_a sql = query_raw.call_args.args[0] inner = sql[sql.index("FROM (") : sql.index(") recent")] assert "LIMIT $3" in inner - assert query_raw.call_args.args[3] == MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS + assert query_raw.call_args.args[3] == SPEND_LOGS_FILTER_SCAN_CAP assert 'ORDER BY "startTime" DESC' in inner +def test_spend_logs_filter_scan_cap_matches_the_logs_page_bound(): + """Pin the cap's value, not just that it is passed through. + + Asserting the param equals the constant is tautological: raising the constant + to a billion keeps that assertion green while removing the bound entirely. + The documented rationale is that both reads of LiteLLM_SpendLogs stop at the + same depth, so tie it to the count cap ui_view_spend_logs already uses. + """ + from litellm.proxy.management_endpoints.customer_endpoints import SPEND_LOGS_FILTER_SCAN_CAP + from litellm.proxy.spend_tracking.spend_management_endpoints import ( + SPEND_LOGS_PAGINATION_COUNT_CAP, + ) + + assert SPEND_LOGS_FILTER_SCAN_CAP == SPEND_LOGS_PAGINATION_COUNT_CAP + + def test_customer_aliases_breaks_start_time_ties_deterministically(mock_prisma_client, mock_user_api_key_auth): """Without a unique tiebreaker the capped scan can cut differently per request, so OFFSET page 2 would page through a different set than page 1 did.""" diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index a8b5dd5a28a..bf33e1ec270 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -2789,7 +2789,7 @@ export interface paths { * * Reads spend logs rather than LiteLLM_EndUserTable because only spend logs carry * the team attribution this scoping needs. The window is required and the inner - * scan is capped at MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS rows, so the query + * scan is capped at SPEND_LOGS_FILTER_SCAN_CAP rows, so the query * cannot degrade into a full-table scan the way `/global/all_end_users` does. * * Example curl: