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;