This commit is contained in:
Xinliang Zheng 2026-11-04 03:26:15 +00:00 • committed by GitHub
commit cc46bc5819
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 474 additions and 34 deletions

View file

@ -2607,6 +2607,10 @@ async def ui_view_spend_logs(
),
model_group: str | None = fastapi.Query(default=None, description="Filter logs by model group"),
key_alias: str | None = fastapi.Query(default=None, description="Filter logs by key alias"),
exclude_key_alias: str | None = fastapi.Query(
default=None,
description="Exclude logs whose key alias exactly matches this value; logs without an alias remain included",
),
end_user: str | None = fastapi.Query(default=None, description="Filter logs by end user"),
error_code: str | None = fastapi.Query(default=None, description="Filter logs by error code (e.g., '404', '500')"),
error_message: str | None = fastapi.Query(
@ -2963,6 +2967,10 @@ async def ui_view_spend_logs(
sql_conditions.append(f"metadata->>'user_api_key_alias' LIKE ${p}")
sql_params.append(f"%{key_alias}%")
p += 1
if exclude_key_alias:
sql_conditions.append(f"metadata->>'user_api_key_alias' IS DISTINCT FROM ${p}")
sql_params.append(exclude_key_alias)
p += 1 # rebind-ok: advances the existing shared SQL placeholder counter
if error_code is not None:
sql_conditions.append(f"metadata->'error_information'->>'error_code' = ${p}")
sql_params.append(error_code)

View file

@ -129,6 +129,7 @@ def _reconstruct_ui_where_from_sql(sql_query, params):
gte = re.search(r'"startTime" >= \(\$(\d+)', cond)
lte = re.search(r'"startTime" <= \(\$(\d+)', cond)
alias = re.search(r"user_api_key_alias' LIKE \$(\d+)", cond)
excluded_alias = re.search(r"user_api_key_alias' IS DISTINCT FROM \$(\d+)", cond)
code = re.search(r"error_code' = \$(\d+)", cond)
msg = re.search(r"error_message' LIKE \$(\d+)", cond)
credential = re.fullmatch(r"metadata->>'used_client_oauth_token' = \$(\d+)", cond)
@ -175,6 +176,8 @@ def _reconstruct_ui_where_from_sql(sql_query, params):
"string_contains": str(params[int(alias.group(1)) - 1]).strip("%"),
}
)
elif excluded_alias:
where["exclude_key_alias"] = params[int(excluded_alias.group(1)) - 1]
elif code:
metadata_conds.append(
{
@ -3708,6 +3711,71 @@ async def test_ui_view_spend_logs_with_model_group(client, monkeypatch):
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
@pytest.mark.parametrize("route", ["/spend/logs/ui", "/spend/logs/v2"])
async def test_ui_view_spend_logs_excludes_exact_key_alias_before_pagination(client, monkeypatch, route):
aliases = ["noisy", "noisy-other", None, "quiet", "noisy"]
logs = [
{
"id": f"log{i}",
"request_id": f"req{i}",
"api_key": "sk-test-key",
"spend": 0.05,
"startTime": datetime.datetime.now(timezone.utc).isoformat(),
"model": "gpt-4",
"metadata": json.dumps({"user_api_key_alias": alias}) if alias is not None else "{}",
}
for i, alias in enumerate(aliases)
]
queries = []
def filter_by_alias(where):
if where.get("exclude_key_alias") != "noisy":
return logs
return [log for log, alias in zip(logs, aliases) if alias != "noisy"]
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client",
make_ui_spend_logs_mock_prisma(
logs, filter_by_alias, query_observer=lambda sql, params: queries.append((sql, params))
),
)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
)
try:
start_date, end_date = _default_date_range()
responses = [
client.get(
route,
params={
"exclude_key_alias": "noisy",
"start_date": start_date,
"end_date": end_date,
"page": page,
"page_size": 2,
},
headers={"Authorization": "Bearer sk-test"},
)
for page in (1, 2)
]
assert [response.status_code for response in responses] == [200, 200]
assert [response.json()["total"] for response in responses] == [3, 3]
assert [response.json()["total_pages"] for response in responses] == [2, 2]
assert [[row["request_id"] for row in response.json()["data"]] for response in responses] == [
["req1", "req2"],
["req3"],
]
for sql, params in queries:
if "WHERE" not in sql:
continue
alias_placeholder = re.search(r"user_api_key_alias' IS DISTINCT FROM \$(\d+)", sql)
assert alias_placeholder is not None
assert params[int(alias_placeholder.group(1)) - 1] == "noisy"
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_with_key_hash(client, monkeypatch):
mock_spend_logs = [
@ -7376,6 +7444,77 @@ async def test_ui_view_spend_logs_group_by_session_first_page(client, monkeypatc
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_grouped_exclusion_reaches_page_count_and_representatives(client, monkeypatch):
page_rows = [
_session_page_row("sess-1", "2026-08-29 10:00:00"),
_session_page_row("sess-2", "2026-08-29 09:00:00"),
_session_page_row("sess-3", "2026-08-29 08:00:00"),
]
session_aliases = {"sess-1": "noisy", "sess-2": "quiet", "sess-3": "quiet"}
representatives = []
for request_id, session_id in (("req-1", "sess-1"), ("req-2", "sess-2"), ("req-3", "sess-3")):
representative = _session_representative_row(request_id, session_id)
representative["metadata"] = {"user_api_key_alias": session_aliases[session_id]}
representatives.append(representative)
async def mock_query_raw(sql_query, *params):
excluded_alias_match = re.search(r"user_api_key_alias' IS DISTINCT FROM \$(\d+)", sql_query)
excluded_alias = params[int(excluded_alias_match.group(1)) - 1] if excluded_alias_match else None
visible_session_ids = {session_id for session_id, alias in session_aliases.items() if alias != excluded_alias}
if "COUNT(*) AS total_count" in sql_query:
return [{"total_count": len(visible_session_ids)}]
if "DISTINCT ON" in sql_query:
requested_sessions = set(params[-2])
requested_api_keys = set(params[-1])
return [
representative
for representative in representatives
if representative["session_id"] in visible_session_ids
and representative["session_id"] in requested_sessions
and representative["api_key"] in requested_api_keys
]
if "COALESCE(SUM(spend)" in sql_query:
return []
return [row for row in page_rows if row["session_key"] in visible_session_ids]
mock_prisma = MagicMock()
mock_prisma.db = MagicMock()
mock_prisma.db.query_raw = AsyncMock(side_effect=mock_query_raw)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
monkeypatch.setattr(
"litellm.proxy.spend_tracking.spend_management_endpoints._is_admin_view_safe",
lambda user_api_key_dict: True,
)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
)
try:
start_date, end_date = _default_date_range()
response = client.get(
"/spend/logs/ui",
params={
"start_date": start_date,
"end_date": end_date,
"group_by_session": "true",
"exclude_key_alias": "noisy",
"page_size": 1,
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200, response.text
assert response.json()["total"] == 2
assert [row["request_id"] for row in response.json()["data"]] == ["req-2"]
queries = [call.args for call in mock_prisma.db.query_raw.await_args_list]
assert len(queries) >= 3
for sql, *params in queries[:3]:
alias_placeholder = re.search(r"user_api_key_alias' IS DISTINCT FROM \$(\d+)", sql)
assert alias_placeholder is not None
assert params[int(alias_placeholder.group(1)) - 1] == "noisy"
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_group_by_session_cursor_page(client, monkeypatch):
"""A session_cursor becomes a keyset predicate on the read's sessions instead of an OFFSET."""

View file

@ -2137,4 +2137,4 @@
"count": 1
}
}
}
}

View file

@ -47,8 +47,8 @@ function renderFilters(filters: Record<string, string> = {}) {
return { set };
}
function StatefulFilters() {
const [filters, setFilters] = useState<Record<string, string | undefined>>({});
function StatefulFilters({ initialFilters = {} }: { initialFilters?: Record<string, string | undefined> }) {
const [filters, setFilters] = useState<Record<string, string | undefined>>(initialFilters);
return (
<RequestLogsFilters
get={(id: string) => filters[id]}
@ -187,6 +187,48 @@ describe("RequestLogsFilters", () => {
expect(useInfiniteKeyAliases).toHaveBeenCalledWith(50, undefined, undefined);
});
it("switches Key Alias from is to is not while keeping the selected alias", async () => {
const user = userEvent.setup();
const { set } = renderFilters({ [LOG_FILTER_IDS.KEY_ALIAS]: "noisy" });
await user.click(screen.getByRole("combobox", { name: "Key Alias operator" }));
await user.click(await screen.findByRole("option", { name: "is not" }));
expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.KEY_ALIAS, undefined);
expect(set).toHaveBeenCalledWith("exclude_key_alias", "noisy");
});
it("selects an alias with the is not operator from an empty filter", async () => {
vi.mocked(useInfiniteKeyAliases).mockReturnValue({
...emptyInfiniteQuery,
data: { pages: [{ aliases: ["noisy"] }], pageParams: [1] },
} as unknown as ReturnType<typeof useInfiniteKeyAliases>);
const user = userEvent.setup();
const { set } = renderFilters();
await user.click(screen.getByRole("combobox", { name: "Key Alias operator" }));
await user.click(await screen.findByRole("option", { name: "is not" }));
await chooseSelectOption(user, screen.getByPlaceholderText("Search a key alias"), "noisy");
expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS, "noisy");
});
it("keeps a restored is not operator when clearing the alias to choose another", async () => {
vi.mocked(useInfiniteKeyAliases).mockReturnValue({
...emptyInfiniteQuery,
data: { pages: [{ aliases: ["noisy", "quiet"] }], pageParams: [1] },
} as unknown as ReturnType<typeof useInfiniteKeyAliases>);
const user = userEvent.setup();
renderWithProviders(<StatefulFilters initialFilters={{ [LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS]: "noisy" }} />);
await user.click(screen.getByRole("button", { name: "Clear", hidden: true }));
expect(screen.getByRole("combobox", { name: "Key Alias operator" })).toHaveTextContent("is not");
await chooseSelectOption(user, screen.getByPlaceholderText("Search a key alias"), "quiet");
expect(screen.getByRole("combobox", { name: "Key Alias operator" })).toHaveTextContent("is not");
expect(screen.getByPlaceholderText("Search a key alias")).toHaveValue("quiet");
});
it("does not leak the team scope into the Model lookup", async () => {
renderFilters({ [LOG_FILTER_IDS.TEAM_ID]: "team-42" });

View file

@ -25,6 +25,11 @@ import { CREDENTIAL_LABELS, ERROR_CODE_OPTIONS } from "../constants";
import { LOG_FILTER_IDS, type LogsWindow } from "./useLogFilterLogic";
const ALL_VALUE = "all";
const KEY_ALIAS_OPERATORS = [
{ value: "is", label: "is" },
{ value: "is not", label: "is not" },
] as const;
type KeyAliasOperator = (typeof KEY_ALIAS_OPERATORS)[number]["value"];
const STATUS_FILTER_ITEMS = [
{ value: ALL_VALUE, label: "All Statuses" },
@ -91,14 +96,32 @@ function TeamFilterField({
function KeyAliasFilterField({
value,
excludeValue,
onChange,
onExcludeChange,
teamId,
}: {
value: string;
excludeValue: string;
onChange: (value: string | undefined) => void;
onExcludeChange: (value: string | undefined) => void;
teamId: string;
}) {
const [search, setSearch] = useState("");
const [operator, setOperator] = useState<KeyAliasOperator>(excludeValue !== "" ? "is not" : "is");
let activeOperator: KeyAliasOperator = operator;
if (excludeValue !== "") {
activeOperator = "is not";
} else if (value !== "") {
activeOperator = "is";
}
const handleOperatorChange = (next: KeyAliasOperator | null) => {
if (next === null || next === activeOperator) return;
const selectedAlias = value || excludeValue || undefined;
setOperator(next);
onChange(next === "is" ? selectedAlias : undefined);
onExcludeChange(next === "is not" ? selectedAlias : undefined);
};
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteKeyAliases(
PAGE_SIZE,
emptyToUndefined(search),
@ -118,10 +141,24 @@ function KeyAliasFilterField({
return (
<DataTableFilterField label="Key Alias">
<Select items={KEY_ALIAS_OPERATORS} value={activeOperator} onValueChange={handleOperatorChange}>
<SelectTrigger className="w-full" aria-label="Key Alias operator">
<SelectValue />
</SelectTrigger>
<SelectContent>
{KEY_ALIAS_OPERATORS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<PaginatedSearchSelect
options={options}
value={value}
onValueChange={(next) => onChange(next ?? undefined)}
value={activeOperator === "is not" ? excludeValue : value}
onValueChange={(next) =>
activeOperator === "is not" ? onExcludeChange(next ?? undefined) : onChange(next ?? undefined)
}
onSearchChange={setSearch}
onLoadMore={() => void fetchNextPage()}
hasNextPage={hasNextPage}
@ -425,7 +462,9 @@ export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsF
<KeyAliasFilterField
value={valueOf(LOG_FILTER_IDS.KEY_ALIAS)}
excludeValue={valueOf(LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS)}
onChange={setter(LOG_FILTER_IDS.KEY_ALIAS)}
onExcludeChange={setter(LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS)}
teamId={valueOf(LOG_FILTER_IDS.TEAM_ID)}
/>

View file

@ -55,10 +55,28 @@ vi.mock("../detail", () => ({
const debounce = vi.hoisted(() => ({ settled: null as string | null }));
vi.mock("@tanstack/react-pacer/debouncer", () => ({
vi.mock("@tanstack/react-pacer/debouncer", async (importOriginal) => ({
...(await importOriginal<typeof import("@tanstack/react-pacer/debouncer")>()),
useDebouncedValue: vi.fn((value: unknown) => [debounce.settled ?? value, { cancel: vi.fn(), flush: vi.fn() }]),
}));
const emptyAliases = vi.hoisted(() => ({
data: { pages: [], pageParams: [] },
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetchingNextPage: false,
isLoading: false,
}));
vi.mock("@/app/(dashboard)/hooks/keys/useKeyAliases", () => ({ useInfiniteKeyAliases: () => emptyAliases }));
vi.mock("@/app/(dashboard)/hooks/models/useModels", async (importOriginal) => ({
...(await importOriginal<typeof import("@/app/(dashboard)/hooks/models/useModels")>()),
useInfiniteModelInfo: () => emptyAliases,
}));
vi.mock("@/app/(dashboard)/hooks/spendLogs/useSpendLogUsers", () => ({ useInfiniteSpendLogUsers: () => emptyAliases }));
vi.mock("@/app/(dashboard)/hooks/spendLogs/useSpendLogEndUsers", () => ({
useInfiniteSpendLogEndUsers: () => emptyAliases,
}));
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { uiSpendLogsCall } from "../../networking";
@ -321,6 +339,46 @@ describe("RequestLogsPanel", () => {
});
});
it("drops the cursor and returns to the first page when the alias changes through URL navigation", async () => {
const firstPage = Array.from({ length: 50 }, (_, index) => logEntry({ request_id: `req-${index}` }));
vi.mocked(uiSpendLogsCall).mockImplementation(async ({ page }) => ({
data: firstPage,
total: 80,
page: page ?? 1,
page_size: 50,
total_pages: 2,
next_session_cursor: "2026-07-07 09:50:13|key-1|sess-1",
has_more: true,
}));
const tree = (searchParams: string) => (
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
<QueryClientProvider client={testQueryClient}>
<RequestLogsPanel {...defaultProps} />
</QueryClientProvider>
</NuqsTestingAdapter>
);
const view = render(tree(""));
await waitFor(() => expect(row("req-0")).not.toBeNull());
fireEvent.click(screen.getByTestId("pagination-next"));
await waitFor(() => expect(lastCall()?.page).toBe(2));
const callCountBeforeAliasNavigation = vi.mocked(uiSpendLogsCall).mock.calls.length;
view.rerender(tree("exclude_key_alias=noisy"));
await waitFor(() => {
const aliasCalls = vi
.mocked(uiSpendLogsCall)
.mock.calls.slice(callCountBeforeAliasNavigation)
.map(([options]) => options)
.filter((options) => options.params?.exclude_key_alias === "noisy");
expect(aliasCalls.length).toBeGreaterThan(0);
expect(aliasCalls.every((options) => options.page === 1 && options.params?.session_cursor === undefined)).toBe(
true,
);
});
});
it("ignores another next click while the next page is still fetching", async () => {
const firstPage = Array.from({ length: 50 }, (_, index) => logEntry({ request_id: `req-${index}` }));
const firstResponse = {
@ -479,6 +537,78 @@ describe("RequestLogsPanel", () => {
});
describe("time range", () => {
it("applies is not to an existing Key Alias and puts the exclusion in the URL", async () => {
const user = userEvent.setup();
renderPanel("key_alias=noisy");
await waitFor(() => expect(lastCall()?.params?.key_alias).toBe("noisy"));
await user.click(screen.getByTestId("datatable-filters-trigger"));
await user.click(screen.getByRole("combobox", { name: "Key Alias operator" }));
await user.click(await screen.findByRole("option", { name: "is not" }));
await user.click(screen.getByTestId("filter-drawer-apply"));
await waitFor(() => expect(urlParams().get("exclude_key_alias")).toBe("noisy"));
expect(urlParams().get("key_alias")).toBeNull();
await waitFor(() => expect(lastCall()?.params?.exclude_key_alias).toBe("noisy"));
expect(lastCall()?.params?.key_alias).toBeUndefined();
});
it("restores an excluded Key Alias from a bookmarked URL and clears it on reset", async () => {
const user = userEvent.setup();
renderPanel("exclude_key_alias=noisy");
await waitFor(() => expect(lastCall()?.params?.exclude_key_alias).toBe("noisy"));
expect(screen.getByTestId("filter-chip-exclude_key_alias")).toHaveTextContent("noisy");
await user.click(screen.getByRole("button", { name: "Reset Filters" }));
await waitFor(() => expect(urlParams().get("exclude_key_alias")).toBeNull());
await waitFor(() => expect(lastCall()?.params?.exclude_key_alias).toBeUndefined());
});
it.each([" noisy ", " "])(
"preserves the exact excluded alias %j when reapplying bookmarked filters",
async (alias) => {
const user = userEvent.setup();
renderPanel(new URLSearchParams({ exclude_key_alias: alias }).toString());
await waitFor(() => expect(lastCall()?.params?.exclude_key_alias).toBe(alias));
await user.click(screen.getByTestId("datatable-filters-trigger"));
await user.click(screen.getByTestId("filter-drawer-apply"));
await waitFor(() => expect(urlParams().get("exclude_key_alias")).toBe(alias));
expect(lastCall()?.params?.exclude_key_alias).toBe(alias);
},
);
it("treats conflicting Key Alias URL parameters as one mutually exclusive filter", async () => {
renderPanel("key_alias=quiet&exclude_key_alias=noisy");
await waitFor(() => expect(lastCall()?.params?.exclude_key_alias).toBe("noisy"));
expect(lastCall()?.params?.key_alias).toBeUndefined();
expect(screen.getByTestId("filter-chip-exclude_key_alias")).toHaveTextContent("noisy");
expect(screen.queryByTestId("filter-chip-key_alias")).not.toBeInTheDocument();
});
it("keeps an excluded Key Alias on the next page", async () => {
vi.mocked(uiSpendLogsCall).mockResolvedValue({
data: Array.from({ length: 25 }, (_, index) => logEntry({ request_id: `req-${index}` })),
total: 60,
page: 1,
page_size: 25,
total_pages: 3,
next_session_cursor: "2026-07-07 09:50:13|key-1|sess-1",
has_more: true,
});
renderPanel("exclude_key_alias=noisy");
await waitFor(() => expect(row("req-0")).not.toBeNull());
fireEvent.click(screen.getByTestId("pagination-next"));
await waitFor(() => expect(lastCall()?.page).toBe(2));
expect(lastCall()?.params?.exclude_key_alias).toBe("noisy");
});
it("requests a ~15 minute window when Last 15 Minutes is picked", async () => {
const user = userEvent.setup();
renderPanel();

View file

@ -4,7 +4,8 @@ import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
import type { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
import moment from "moment";
import { useCallback, useEffect, useMemo, useState } from "react";
import { parseAsString, useQueryStates } from "nuqs";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { DEFAULT_PAGE_SIZE_OPTIONS } from "@/components/shared/DataTable";
import { Button } from "@/components/ui/button";
@ -17,6 +18,8 @@ import type { LogEntry } from "../types";
import {
DEFAULT_LOGS_SORTING,
formatLogsWindow,
getExactFilterValue,
getFilterValue,
getLogsWindowEndBound,
LOG_FILTER_IDS,
type PaginatedResponse,
@ -49,8 +52,33 @@ interface RequestLogsPanelProps {
export default function RequestLogsPanel({ accessToken, token, userRole, userID, isActive }: RequestLogsPanelProps) {
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: PAGE_SIZE });
const [sorting, setSorting] = useState<SortingState>(DEFAULT_LOGS_SORTING);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [localFilters, setLocalFilters] = useState<ColumnFiltersState>([]);
const [{ key_alias, exclude_key_alias }, setAliasParams] = useQueryStates(
{ key_alias: parseAsString, exclude_key_alias: parseAsString },
{ history: "push" },
);
const [observedAliasParams, setObservedAliasParams] = useState({ key_alias, exclude_key_alias });
const columnFilters = useMemo<ColumnFiltersState>(() => {
const aliasFilters: ColumnFiltersState = [];
if (exclude_key_alias) {
aliasFilters.push({ id: LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS, value: exclude_key_alias });
} else if (key_alias) {
aliasFilters.push({ id: LOG_FILTER_IDS.KEY_ALIAS, value: key_alias });
}
return [...localFilters, ...aliasFilters];
}, [localFilters, key_alias, exclude_key_alias]);
const columnFiltersRef = useRef<ColumnFiltersState>(columnFilters);
useEffect(() => {
columnFiltersRef.current = columnFilters;
}, [columnFilters]);
const [sessionCursors, setSessionCursors] = useState<Record<number, string>>({});
const aliasParamsChanged =
observedAliasParams.key_alias !== key_alias || observedAliasParams.exclude_key_alias !== exclude_key_alias;
const effectivePagination = useMemo(
() => (aliasParamsChanged ? { ...pagination, pageIndex: 0 } : pagination),
[aliasParamsChanged, pagination],
);
const effectiveSessionCursors = aliasParamsChanged ? {} : sessionCursors;
const [timeRange, setTimeRange] = useState<LogsTimeRange>(defaultLogsTimeRange);
const { startTime, endTime, isCustomDate } = timeRange;
@ -105,10 +133,10 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
excludeInternalHealthChecks,
startTime,
endTime,
pagination,
pagination: effectivePagination,
isCustomDate,
sorting,
sessionCursors,
sessionCursors: effectiveSessionCursors,
});
// Follow the table's own last fetch so a live-tail refresh carries the filter
@ -173,13 +201,13 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
const isDrawerOpen = displayLog !== null || displaySessionId !== null;
const rows: LogEntry[] = filteredLogs.data;
const rowsThroughThisPage = pagination.pageIndex * pagination.pageSize + rows.length;
const rowsThroughThisPage = effectivePagination.pageIndex * effectivePagination.pageSize + rows.length;
const isLastPage =
filteredLogs.has_more === false || (filteredLogs.has_more === undefined && rows.length < pagination.pageSize);
const rowCount = isLastPage ? rowsThroughThisPage : Math.max(filteredLogs.total, rowsThroughThisPage);
const handleSearchChange = useCallback((value: string) => {
setColumnFilters((previous) => {
setLocalFilters((previous) => {
const others = previous.filter((filter) => filter.id !== LOG_FILTER_IDS.SEARCH);
return value === "" ? others : [...others, { id: LOG_FILTER_IDS.SEARCH, value }];
});
@ -193,30 +221,66 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
}, []);
const handleColumnFiltersChange = useCallback<OnChangeFn<ColumnFiltersState>>((updaterOrValue) => {
setColumnFilters(updaterOrValue);
setSessionCursors({});
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
}, []);
const handleColumnFiltersChange = useCallback<OnChangeFn<ColumnFiltersState>>(
(updaterOrValue) => {
const next = typeof updaterOrValue === "function" ? updaterOrValue(columnFiltersRef.current) : updaterOrValue;
const nextKeyAlias = getFilterValue(next, LOG_FILTER_IDS.KEY_ALIAS);
const nextExcludeKeyAlias = getExactFilterValue(next, LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS);
const normalizedNext = next.filter(
(filter) => filter.id !== LOG_FILTER_IDS.KEY_ALIAS && filter.id !== LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS,
);
if (nextExcludeKeyAlias !== undefined) {
normalizedNext.push({ id: LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS, value: nextExcludeKeyAlias });
} else if (nextKeyAlias !== undefined) {
normalizedNext.push({ id: LOG_FILTER_IDS.KEY_ALIAS, value: nextKeyAlias });
}
columnFiltersRef.current = normalizedNext;
setLocalFilters(
normalizedNext.filter(
(filter) => filter.id !== LOG_FILTER_IDS.KEY_ALIAS && filter.id !== LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS,
),
);
void setAliasParams({
key_alias: nextExcludeKeyAlias === undefined ? nextKeyAlias ?? null : null,
exclude_key_alias: nextExcludeKeyAlias ?? null,
});
setSessionCursors({});
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
},
[setAliasParams],
);
const resetToFirstPage = useCallback(() => {
setSessionCursors({});
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
}, []);
const resetToFirstPage = useCallback(
(nextAliasParams?: { key_alias: string | null; exclude_key_alias: string | null }) => {
if (nextAliasParams) {
setObservedAliasParams(nextAliasParams);
}
setSessionCursors({});
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
},
[],
);
useEffect(() => {
if (!aliasParamsChanged) return;
// Browser history changes arrive outside the table event handlers, so local cursor state must follow the URL.
// eslint-disable-next-line react-hooks/set-state-in-effect
resetToFirstPage({ key_alias, exclude_key_alias });
}, [aliasParamsChanged, key_alias, exclude_key_alias, resetToFirstPage]);
const handlePaginationChange = useCallback<OnChangeFn<PaginationState>>(
(updaterOrValue) => {
const requested = typeof updaterOrValue === "function" ? updaterOrValue(pagination) : updaterOrValue;
const requested = typeof updaterOrValue === "function" ? updaterOrValue(effectivePagination) : updaterOrValue;
if (!usesSessionCursor) {
setPagination(requested);
return;
}
if (requested.pageSize !== pagination.pageSize) {
if (requested.pageSize !== effectivePagination.pageSize) {
setSessionCursors({});
setPagination({ ...requested, pageIndex: 0 });
return;
}
if (requested.pageIndex !== pagination.pageIndex + 1) {
if (requested.pageIndex !== effectivePagination.pageIndex + 1) {
setPagination(requested);
return;
}
@ -225,7 +289,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
setSessionCursors((previous) => ({ ...previous, [requested.pageIndex]: nextCursor }));
setPagination(requested);
},
[usesSessionCursor, pagination, filteredLogs.next_session_cursor, logsQuery.isPlaceholderData],
[usesSessionCursor, effectivePagination, filteredLogs.next_session_cursor, logsQuery.isPlaceholderData],
);
const handleExcludeInternalHealthChecksChange = useCallback(
@ -245,10 +309,11 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
);
const handleResetFilters = useCallback(() => {
setColumnFilters([]);
setLocalFilters([]);
void setAliasParams({ key_alias: null, exclude_key_alias: null });
setTimeRange(defaultLogsTimeRange());
resetToFirstPage();
}, [resetToFirstPage]);
}, [resetToFirstPage, setAliasParams]);
const handleRowClick = useCallback(
(log: LogEntry) => {
@ -304,7 +369,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
isError={logsQuery.isError}
errorMessage={logsQuery.error instanceof Error ? logsQuery.error.message : undefined}
isRefreshing={logsQuery.isFetching}
pagination={pagination}
pagination={effectivePagination}
onPaginationChange={handlePaginationChange}
sorting={sorting}
onSortingChange={handleSortingChange}
@ -322,7 +387,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID,
<LogsToolbar>
<LogsTimeRangePicker value={timeRange} onValueChange={handleTimeRangeChange} />
<LogsToolbarSwitch label="Live Tail" checked={isLiveTail} onCheckedChange={setIsLiveTail} />
{isLiveTail && pagination.pageIndex === 0 && (
{isLiveTail && effectivePagination.pageIndex === 0 && (
<span role="status" className="whitespace-nowrap text-xs text-muted-foreground">
Refreshing every 15s
</span>

View file

@ -92,6 +92,9 @@ describe("useLogFilterLogic", () => {
{ id: LOG_FILTER_IDS.MODEL_ID, value: "model-uuid-1", param: "model_id" },
{ id: LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL, value: "gpt-4o", param: "model" },
{ id: LOG_FILTER_IDS.KEY_ALIAS, value: "alias-1", param: "key_alias" },
{ id: LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS, value: "alias-1", param: "exclude_key_alias" },
{ id: LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS, value: " alias-1 ", param: "exclude_key_alias" },
{ id: LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS, value: " ", param: "exclude_key_alias" },
{ id: LOG_FILTER_IDS.ERROR_CODE, value: "429", param: "error_code" },
{ id: LOG_FILTER_IDS.ERROR_MESSAGE, value: "rate limited", param: "error_message" },
{ id: LOG_FILTER_IDS.USER_ID, value: "user-9", param: "user_id" },
@ -110,6 +113,7 @@ describe("useLogFilterLogic", () => {
columnFilters: [
{ id: LOG_FILTER_IDS.TEAM_ID, value: " " },
{ id: LOG_FILTER_IDS.KEY_HASH, value: "" },
{ id: LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS, value: "" },
],
});
@ -117,6 +121,7 @@ describe("useLogFilterLogic", () => {
const params = lastCallParams()?.params;
expect(params?.team_id).toBeUndefined();
expect(params?.api_key).toBeUndefined();
expect(params?.exclude_key_alias).toBeUndefined();
expect(params?.error_code).toBeUndefined();
});
});

View file

@ -26,6 +26,7 @@ export const LOG_FILTER_IDS = {
CACHE_STATUS: "cache_hit",
CREDENTIAL: "used_client_oauth_token",
KEY_ALIAS: "key_alias",
EXCLUDE_KEY_ALIAS: "exclude_key_alias",
END_USER: "end_user",
ERROR_CODE: "error_code",
ERROR_MESSAGE: "error_message",
@ -45,6 +46,7 @@ export const LOG_FILTER_LABELS: Record<string, string> = {
[LOG_FILTER_IDS.CACHE_STATUS]: "Cache",
[LOG_FILTER_IDS.CREDENTIAL]: "Credential",
[LOG_FILTER_IDS.KEY_ALIAS]: "Key Alias",
[LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS]: "Key Alias is not",
[LOG_FILTER_IDS.USER_ID]: "User ID",
[LOG_FILTER_IDS.END_USER]: "End User",
[LOG_FILTER_IDS.ERROR_CODE]: "Error Code",
@ -96,10 +98,13 @@ export const DEFAULT_LOGS_SORTING: SortingState = [{ id: "startTime", desc: true
const isSortField = (id: string): id is LogsSortField => Object.hasOwn(LOGS_SORT_FIELD_MAP, id);
export const getFilterValue = (columnFilters: ColumnFiltersState, columnId: string): string | undefined => {
export const getExactFilterValue = (columnFilters: ColumnFiltersState, columnId: string): string | undefined => {
const entry = columnFilters.find((filter) => filter.id === columnId);
if (typeof entry?.value !== "string") return undefined;
const trimmed = entry.value.trim();
return typeof entry?.value === "string" && entry.value !== "" ? entry.value : undefined;
};
export const getFilterValue = (columnFilters: ColumnFiltersState, columnId: string): string | undefined => {
const trimmed = getExactFilterValue(columnFilters, columnId)?.trim();
return trimmed === "" ? undefined : trimmed;
};
@ -171,7 +176,7 @@ export function useLogFilterLogic({
const userIdFilter = getFilterValue(columnFilters, LOG_FILTER_IDS.USER_ID);
return await uiSpendLogsCall({
const spendLogsRequest: Parameters<typeof uiSpendLogsCall>[0] = {
accessToken,
start_date: window.start_date,
end_date: window.end_date,
@ -192,6 +197,7 @@ export function useLogFilterLogic({
model_id: getFilterValue(columnFilters, LOG_FILTER_IDS.MODEL_ID),
model: getFilterValue(columnFilters, LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL),
key_alias: getFilterValue(columnFilters, LOG_FILTER_IDS.KEY_ALIAS),
exclude_key_alias: getExactFilterValue(columnFilters, LOG_FILTER_IDS.EXCLUDE_KEY_ALIAS),
error_code: getFilterValue(columnFilters, LOG_FILTER_IDS.ERROR_CODE),
error_message: getFilterValue(columnFilters, LOG_FILTER_IDS.ERROR_MESSAGE),
sort_by: sortBy,
@ -200,7 +206,8 @@ export function useLogFilterLogic({
group_by_session: true,
session_cursor: sessionCursor,
},
});
};
return await uiSpendLogsCall(spendLogsRequest);
},
enabled: !!accessToken && !!token && !!userRole && !!userID && activeTab === "request logs",
refetchInterval: getLiveTailRefetchInterval(isLiveTail, pagination.pageIndex),

View file

@ -1901,6 +1901,7 @@ interface UiSpendLogsParams {
/** Filter by model ID (litellm model deployment id) */
model_id?: string;
key_alias?: string;
exclude_key_alias?: string;
error_code?: string;
error_message?: string;
sort_by?: string;

View file

@ -73389,6 +73389,8 @@ export interface operations {
model_group?: string | null;
/** @description Filter logs by key alias */
key_alias?: string | null;
/** @description Exclude logs whose key alias exactly matches this value; logs without an alias remain included */
exclude_key_alias?: string | null;
/** @description Filter logs by end user */
end_user?: string | null;
/** @description Filter logs by error code (e.g., '404', '500') */
@ -73511,6 +73513,8 @@ export interface operations {
model_group?: string | null;
/** @description Filter logs by key alias */
key_alias?: string | null;
/** @description Exclude logs whose key alias exactly matches this value; logs without an alias remain included */
exclude_key_alias?: string | null;
/** @description Filter logs by end user */
end_user?: string | null;
/** @description Filter logs by error code (e.g., '404', '500') */