mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-10 08:28:25 +00:00
Merge 01dde98236 into cbe361d970
This commit is contained in:
commit
cc46bc5819
11 changed files with 474 additions and 34 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -2137,4 +2137,4 @@
|
|||
"count": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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') */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue