mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
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:
parent
7263aa0028
commit
745f7ad163
10 changed files with 464 additions and 46 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
|
||||
|
|
|
|||
|
|
@ -232,7 +232,6 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
|
|||
onKeyHashClick={handleKeyHashClick}
|
||||
onSessionClick={handleSessionClick}
|
||||
teams={allTeams ?? []}
|
||||
accessToken={accessToken}
|
||||
toolbarChildren={
|
||||
<LogsTableToolbar
|
||||
startTime={startTime}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
89
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
89
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue