diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 8f530e3b8ce..bce2e3581b5 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -1622,6 +1622,10 @@ async def ui_view_spend_logs( default=None, description="request_id to get spend logs for specific request_id", ), + session_id: str | None = fastapi.Query( + default=None, + description="Filter spend logs by session_id (partial string match)", + ), team_id: str | None = fastapi.Query( default=None, description="Filter spend logs by team_id", @@ -1906,6 +1910,12 @@ async def ui_view_spend_logs( p += 2 sql_conditions.append(or_clause) + if session_id is not None and isinstance(session_id, str): + like_escaped_session_id = session_id.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + sql_conditions.append(f"session_id LIKE ${p}") + sql_params.append(f"%{like_escaped_session_id}%") + p += 1 + # Status filter if status_filter is not None: if status_filter == "success": diff --git a/pyproject.toml b/pyproject.toml index 99425ef3aef..4c70bf66031 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -259,6 +259,7 @@ constraint-dependencies = [ "tornado>=6.5.6", "aiohttp>=3.14.1,<4.0", "packaging>=24.0", + "soupsieve>=2.8.4", ] override-dependencies = [ # a2a-sdk 1.x requires packaging>=24.0; lunary 1.4.x still caps at <24.0. diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py index 1e9818534c9..c3899883d58 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -1,4 +1,5 @@ import asyncio +import collections import datetime import json import os @@ -98,6 +99,7 @@ def _reconstruct_ui_where_from_sql(sql_query, params): alias = re.search(r"user_api_key_alias' LIKE \$(\d+)", cond) code = re.search(r"error_code' = \$(\d+)", cond) msg = re.search(r"error_message' LIKE \$(\d+)", cond) + sess = re.fullmatch(r"session_id LIKE \$(\d+)", cond) status = re.fullmatch(r"status = \$(\d+)", cond) if gte: date_bounds["gte"] = _iso(params[int(gte.group(1)) - 1]) @@ -107,6 +109,8 @@ def _reconstruct_ui_where_from_sql(sql_query, params): where["OR"] = where.get("OR", []) + [{"multi_team": True}] elif "status = 'success'" in cond: where["OR"] = where.get("OR", []) + [{"status": "success"}] + elif sess: + where["session_id"] = {"contains": str(params[int(sess.group(1)) - 1]).strip("%")} elif status: where["status"] = {"equals": params[int(status.group(1)) - 1]} elif alias: @@ -162,7 +166,19 @@ def make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn, team_lookup_fn=No async def count(self, *args, **kwargs): return len(filter_fn(kwargs.get("where", {}))) + async def group_by(self, by, where, count): + col = by[0] + allowed = where.get(col, {}).get("in") + tallied = collections.Counter( + log[col] + for log in mock_spend_logs + if log.get(col) is not None and (allowed is None or log[col] in allowed) + ) + return [{col: value, "_count": {col: n}} for value, n in tallied.items()] + async def query_raw(self, sql_query, *params): + if "mcp_tool_call_count" in sql_query: + return [] filtered = filter_fn(_reconstruct_ui_where_from_sql(sql_query, params)) total = len(filtered) if "COUNT(*)" in sql_query: @@ -597,6 +613,73 @@ async def test_ui_view_spend_logs_with_user_id(client, monkeypatch): assert data["data"][0]["user"] == "test_user_1" +@pytest.mark.asyncio +@pytest.mark.parametrize( + "session_id_query,expected_request_ids", + [ + ("session-filter-demo-1", {"req1", "req2"}), + ("session-filter-demo-2", {"req3"}), + ("session-filter", {"req1", "req2", "req3"}), + ("demo", {"req1", "req2", "req3"}), + ("no-such-session", set()), + ], +) +async def test_ui_view_spend_logs_with_session_id( + client, monkeypatch, session_id_query, expected_request_ids +): + def make_log(request_id, session_id): + return { + "id": f"log-{request_id}", + "request_id": request_id, + "api_key": "sk-test-key", + "user": "test_user_1", + "session_id": session_id, + "spend": 0.05, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + } + + mock_spend_logs = [ + make_log("req1", "session-filter-demo-1"), + make_log("req2", "session-filter-demo-1"), + make_log("req3", "session-filter-demo-2"), + make_log("req4", "unrelated-abc"), + ] + + def filter_by_session(where): + session_filter = where.get("session_id") + if session_filter is None: + return mock_spend_logs + return [ + log + for log in mock_spend_logs + if session_filter["contains"] in log["session_id"] + ] + + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_session), + ) + + start_date, end_date = _default_date_range() + + response = client.get( + "/spend/logs/ui", + params={ + "session_id": session_id_query, + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["total"] == len(expected_request_ids) + assert {log["request_id"] for log in data["data"]} == expected_request_ids + assert all(session_id_query in log["session_id"] for log in data["data"]) + + # Mock spend logs with distinct values for sorting tests. # req_a: spend=0.10, tokens=500, start/end earliest # req_b: spend=0.05, tokens=200, start/end 2nd diff --git a/ui/litellm-dashboard/eslint-metrics.json b/ui/litellm-dashboard/eslint-metrics.json index ded6ab97e1e..37bad071081 100644 --- a/ui/litellm-dashboard/eslint-metrics.json +++ b/ui/litellm-dashboard/eslint-metrics.json @@ -1,7 +1,7 @@ { "@typescript-eslint/no-explicit-any": 1982, "complexity": 128, - "local/no-large-inline-object-arg": 512, + "local/no-large-inline-object-arg": 519, "local/no-long-condition-chain": 233, "max-depth": 59, "no-console": 15 diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 403647106d4..5ec2765c621 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1929,6 +1929,7 @@ interface UiSpendLogsParams { api_key?: string; team_id?: string; request_id?: string; + session_id?: string; user_id?: string; end_user?: string; status_filter?: string; diff --git a/ui/litellm-dashboard/src/components/view_logs/filter_options.ts b/ui/litellm-dashboard/src/components/view_logs/filter_options.ts index 52632ea5861..90e27b6144d 100644 --- a/ui/litellm-dashboard/src/components/view_logs/filter_options.ts +++ b/ui/litellm-dashboard/src/components/view_logs/filter_options.ts @@ -63,6 +63,11 @@ export function getLogFilterOptions(accessToken: string): FilterOption[] { label: "Key Hash", isSearchable: false, }, + { + name: FILTER_KEYS.SESSION_ID, + label: "Session ID", + isSearchable: false, + }, { name: "Model", label: "Model", diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx index cbe37e0b70f..ef550baea91 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx @@ -218,6 +218,7 @@ describe("useLogFilterLogic", () => { { filterKey: "Team ID", paramName: "team_id", value: "team-a" }, { filterKey: "Key Hash", paramName: "api_key", value: "key-x" }, { filterKey: "Request ID", paramName: "request_id", value: "req-xyz" }, + { filterKey: "Session ID", paramName: "session_id", value: "sess-42" }, { filterKey: "User ID", paramName: "user_id", value: "user-123" }, { filterKey: "End User", paramName: "end_user", value: "user-a" }, { filterKey: "Status", paramName: "status_filter", value: "error" }, diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx index c45e03905d0..1d699042f05 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx @@ -30,6 +30,7 @@ export const FILTER_KEYS = { TEAM_ID: "Team ID", KEY_HASH: "Key Hash", REQUEST_ID: "Request ID", + SESSION_ID: "Session ID", MODEL: "Model", /** Exact match on LiteLLM_SpendLogs.model — use for search tools and public model names. */ PUBLIC_MODEL_OR_SEARCH_TOOL: "Public model / search tool", @@ -49,6 +50,7 @@ const TEXT_FILTER_KEYS: readonly (keyof LogFilterState)[] = [ FILTER_KEYS.KEY_HASH, FILTER_KEYS.ERROR_MESSAGE, FILTER_KEYS.REQUEST_ID, + FILTER_KEYS.SESSION_ID, FILTER_KEYS.USER_ID, FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL, ]; @@ -62,6 +64,7 @@ export const defaultFilters: LogFilterState = { [FILTER_KEYS.TEAM_ID]: "", [FILTER_KEYS.KEY_HASH]: "", [FILTER_KEYS.REQUEST_ID]: "", + [FILTER_KEYS.SESSION_ID]: "", [FILTER_KEYS.MODEL]: "", [FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL]: "", [FILTER_KEYS.USER_ID]: "", @@ -160,6 +163,7 @@ export function useLogFilterLogic({ api_key: effectiveFilters[FILTER_KEYS.KEY_HASH] || undefined, team_id: effectiveFilters[FILTER_KEYS.TEAM_ID] || undefined, request_id: effectiveFilters[FILTER_KEYS.REQUEST_ID] || undefined, + session_id: effectiveFilters[FILTER_KEYS.SESSION_ID] || undefined, user_id: effectiveFilters[FILTER_KEYS.USER_ID] || (filterByCurrentUser ? userID ?? undefined : undefined), end_user: effectiveFilters[FILTER_KEYS.END_USER] || undefined, status_filter: effectiveFilters[FILTER_KEYS.STATUS] || undefined, diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 852f8388ec2..4ca2f85be2b 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -48633,6 +48633,8 @@ export interface operations { user_id?: string | null; /** @description request_id to get spend logs for specific request_id */ request_id?: string | null; + /** @description Filter spend logs by session_id (partial string match) */ + session_id?: string | null; /** @description Filter spend logs by team_id */ team_id?: string | null; /** @description Filter logs with spend greater than or equal to this value */ @@ -48739,6 +48741,8 @@ export interface operations { user_id?: string | null; /** @description request_id to get spend logs for specific request_id */ request_id?: string | null; + /** @description Filter spend logs by session_id (partial string match) */ + session_id?: string | null; /** @description Filter spend logs by team_id */ team_id?: string | null; /** @description Filter logs with spend greater than or equal to this value */ diff --git a/uv.lock b/uv.lock index a226ef172c7..692c1c2cb8f 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-07-06T16:10:51.214184Z" +exclude-newer = "2026-07-06T18:05:33.611729Z" exclude-newer-span = "P3D" [manifest] @@ -21,6 +21,7 @@ members = [ constraints = [ { name = "aiohttp", specifier = ">=3.14.1,<4.0" }, { name = "packaging", specifier = ">=24.0" }, + { name = "soupsieve", specifier = ">=2.8.4" }, { name = "tornado", specifier = ">=6.5.6" }, ] overrides = [{ name = "packaging", specifier = ">=24.0" }] @@ -7076,11 +7077,11 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.8.3" +version = "2.8.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, ] [[package]]