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.
This commit is contained in:
Yuneng Jiang 2026-07-24 15:56:34 -07:00
parent 7263aa0028
commit 745f7ad163
No known key found for this signature in database
10 changed files with 464 additions and 46 deletions

View file

@ -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.

View file

@ -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"],

View file

@ -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]

View file

@ -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

View file

@ -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);
};

View file

@ -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<typeof import("../networking")>();
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<string, string> = {}) {
const set = vi.fn();
renderWithProviders(
<RequestLogsFilters get={(id: string) => filters[id]} set={set} teams={[]} accessToken="test-token" />,
);
renderWithProviders(<RequestLogsFilters get={(id: string) => filters[id]} set={set} teams={[]} />);
return { set };
}
@ -47,6 +46,9 @@ describe("RequestLogsFilters", () => {
vi.mocked(useInfiniteModelInfo).mockReturnValue(
emptyInfiniteQuery as unknown as ReturnType<typeof useInfiniteModelInfo>,
);
vi.mocked(useInfiniteEndUserAliases).mockReturnValue(
emptyInfiniteQuery as unknown as ReturnType<typeof useInfiniteEndUserAliases>,
);
});
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<typeof useInfiniteEndUserAliases>);
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<typeof useInfiniteEndUserAliases>);
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());
});
});

View file

@ -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<string[]>({
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<SearchSelectOption[]>(
() => (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<SearchSelectOption[]>(() => {
const seen = new Set<string>();
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 (
<DataTableFilterField label="End User">
<SearchSelect
<PaginatedSearchSelect
options={options}
value={value}
onValueChange={(next) => 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)}
/>
<EndUserFilterField
value={valueOf(LOG_FILTER_IDS.END_USER)}
onChange={setter(LOG_FILTER_IDS.END_USER)}
accessToken={accessToken}
/>
<EndUserFilterField value={valueOf(LOG_FILTER_IDS.END_USER)} onChange={setter(LOG_FILTER_IDS.END_USER)} />
<ErrorCodeFilterField value={valueOf(LOG_FILTER_IDS.ERROR_CODE)} onChange={setter(LOG_FILTER_IDS.ERROR_CODE)} />

View file

@ -232,7 +232,6 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
onKeyHashClick={handleKeyHashClick}
onSessionClick={handleSessionClick}
teams={allTeams ?? []}
accessToken={accessToken}
toolbarChildren={
<LogsTableToolbar
startTime={startTime}

View file

@ -30,7 +30,6 @@ interface RequestLogsTableProps {
onKeyHashClick: (keyHash: string) => 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 }) => <RequestLogsFilters get={get} set={set} teams={teams} accessToken={accessToken} />}
{({ get, set }) => <RequestLogsFilters get={get} set={set} teams={teams} />}
</DataTableFilterDrawer>
</>
)}

View file

@ -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;