From 745f7ad1639d3fc35b26fea8a66a82f4a5b4dfc2 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 24 Jul 2026 15:56:34 -0700 Subject: [PATCH] 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;