diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index a319535f725..b5980f9b224 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -69,6 +69,18 @@ _SESSION_KEY_EXPR: Final = "COALESCE(NULLIF(session_id, ''), request_id)" _SESSION_GROUP_KEY_SQL: Final = f"{_SESSION_KEY_EXPR}, api_key" _MCP_CALL_TYPES_SQL: Final = "('call_mcp_tool', 'list_mcp_tools')" _AGENT_CALL_TYPE_SQL: Final = "'asend_message'" +_BATCH_CALL_TYPES_SQL: Final = "('acreate_batch', 'create_batch', 'aretrieve_batch', 'retrieve_batch')" +_SPAN_TYPE_SQL_CONDITIONS: Final[Mapping[str, str]] = MappingProxyType( + { + "mcp": f"call_type IN {_MCP_CALL_TYPES_SQL}", + "agent": f"call_type = {_AGENT_CALL_TYPE_SQL}", + "batch": f"call_type IN {_BATCH_CALL_TYPES_SQL}", + "llm": ( + f"(call_type NOT IN {_MCP_CALL_TYPES_SQL} AND call_type != {_AGENT_CALL_TYPE_SQL} " + f"AND call_type NOT IN {_BATCH_CALL_TYPES_SQL})" + ), + } +) _SPEND_LOG_LIST_COLUMNS: Final = """ request_id, call_type, api_key, spend, total_tokens, prompt_tokens, completion_tokens, "startTime", "endTime", @@ -2410,6 +2422,10 @@ async def ui_view_spend_logs( default=None, description="Filter logs by cache state: 'hit' or 'miss'. Miss includes legacy rows with a null/unknown cache state", ), + span_type: str | None = fastapi.Query( + default=None, + description="Filter logs by span type: llm, agent, mcp, or batch", + ), model: str | None = fastapi.Query(default=None, description="Filter logs by model"), model_id: str | None = fastapi.Query( default=None, @@ -2512,6 +2528,13 @@ async def ui_view_spend_logs( param="cache_hit_filter", code=status.HTTP_400_BAD_REQUEST, ) + if isinstance(span_type, str) and span_type not in _SPAN_TYPE_SQL_CONDITIONS: + raise ProxyException( + message=f"Invalid span_type: {span_type}. Must be one of: llm, agent, mcp, batch", + type="bad_request", + param="span_type", + code=status.HTTP_400_BAD_REQUEST, + ) try: is_admin_view: Final = _is_admin_view_safe(user_api_key_dict=user_api_key_dict) @@ -2776,6 +2799,10 @@ async def ui_view_spend_logs( elif cache_hit_filter == "miss": sql_conditions.append("(cache_hit IS NULL OR LOWER(cache_hit) != 'true')") + span_type_condition: Final = _span_type_sql_condition(span_type) + if span_type_condition is not None: + sql_conditions.append(span_type_condition) + if exclude_internal_health_checks: sql_conditions.append(f"api_key NOT IN (${p}, ${p + 1})") sql_params.extend(_INTERNAL_HEALTH_CHECK_API_KEYS) @@ -4673,6 +4700,12 @@ def _build_status_filter_condition(status_filter: str | None) -> Mapping[str, ob return {"status": {"equals": status_filter}} +def _span_type_sql_condition(span_type: str | None) -> str | None: + if span_type is None: + return None + return _SPAN_TYPE_SQL_CONDITIONS.get(span_type) + + def _is_admin_view_safe(user_api_key_dict: UserAPIKeyAuth) -> bool: """ Safely determine if the current user has admin view permissions. 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 9de6679472e..16cbd109b24 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 @@ -139,6 +139,15 @@ def _reconstruct_ui_where_from_sql(sql_query, params): where["cache_hit"] = "hit" elif cond == "(cache_hit IS NULL OR LOWER(cache_hit) != 'true')": where["cache_hit"] = "miss" + elif "call_type" in cond: + if "call_type NOT IN" in cond: + where["span_type"] = "llm" + elif "call_mcp_tool" in cond: + where["span_type"] = "mcp" + elif "call_type = 'asend_message'" in cond: + where["span_type"] = "agent" + elif "acreate_batch" in cond: + where["span_type"] = "batch" elif sess: where["session_id"] = {"contains": str(params[int(sess.group(1)) - 1]).strip("%")} elif status: @@ -3418,6 +3427,95 @@ async def test_ui_view_spend_logs_with_cache_hit_filter(client, monkeypatch): app.dependency_overrides.pop(ps.user_api_key_auth, None) +@pytest.mark.asyncio +async def test_ui_view_spend_logs_with_span_type_filter(client, monkeypatch): + base = { + "api_key": "sk-test-key", + "user": "test_user_1", + "team_id": "team1", + "spend": 0.05, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + "status": "success", + } + mock_spend_logs = [ + {**base, "id": "log1", "request_id": "req-llm", "call_type": "acompletion"}, + {**base, "id": "log2", "request_id": "req-agent", "call_type": "asend_message"}, + {**base, "id": "log3", "request_id": "req-mcp", "call_type": "call_mcp_tool"}, + {**base, "id": "log4", "request_id": "req-batch", "call_type": "aretrieve_batch"}, + ] + + call_types_by_span = { + "llm": lambda ct: ct not in {"call_mcp_tool", "list_mcp_tools", "asend_message"} + and ct not in {"acreate_batch", "create_batch", "aretrieve_batch", "retrieve_batch"}, + "agent": lambda ct: ct == "asend_message", + "mcp": lambda ct: ct in {"call_mcp_tool", "list_mcp_tools"}, + "batch": lambda ct: ct + in {"acreate_batch", "create_batch", "aretrieve_batch", "retrieve_batch"}, + } + + def filter_by_span_type(where): + span_type = where.get("span_type") + if span_type is None: + return mock_spend_logs + return [log for log in mock_spend_logs if call_types_by_span[span_type](log["call_type"])] + + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_span_type), + ) + + start_date, end_date = _default_date_range() + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN + ) + try: + for span_type, expected_ids in [ + ("batch", ["req-batch"]), + ("llm", ["req-llm"]), + ("mcp", ["req-mcp"]), + ("agent", ["req-agent"]), + ]: + response = client.get( + "/spend/logs/ui", + params={ + "span_type": span_type, + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 200, response.text + data = response.json() + assert data["total"] == len(expected_ids) + assert [row["request_id"] for row in data["data"]] == expected_ids + + response = client.get( + "/spend/logs/ui", + params={ + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 200 + assert response.json()["total"] == 4 + + response = client.get( + "/spend/logs/ui", + params={ + "span_type": "invalid", + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 400 + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + @pytest.mark.asyncio async def test_ui_view_spend_logs_with_model(client, monkeypatch): mock_spend_logs = [ diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 82399792674..d358d23408c 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -2003,6 +2003,7 @@ interface UiSpendLogsParams { end_user?: string; status_filter?: string; cache_hit_filter?: string; + span_type?: string; /** Filter by model name (e.g. "gpt-4") */ model?: string; /** Filter by model ID (litellm model deployment id) */ diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx index 8d1847e0121..a2da80a3f7b 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx @@ -84,6 +84,7 @@ describe("RequestLogsFilters", () => { for (const label of [ "Team ID", + "Span Type", "Status", "Cache", "Key Alias", @@ -286,6 +287,38 @@ describe("RequestLogsFilters", () => { expect(await screen.findByText(label)).toBeInTheDocument(); }); + it.each([ + ["", "All Types"], + ["llm", "LLM"], + ["agent", "Agent"], + ["mcp", "MCP"], + ["batch", "Batch"], + ])("shows the human label on the Span Type trigger for %s", async (spanType, label) => { + renderFilters(spanType === "" ? {} : { [LOG_FILTER_IDS.SPAN_TYPE]: spanType }); + + expect(await screen.findByText(label)).toBeInTheDocument(); + }); + + it("selecting Batch sets the span_type filter", async () => { + const user = userEvent.setup(); + const { set } = renderFilters(); + + await user.click(await screen.findByText("All Types")); + await user.click(await screen.findByRole("option", { name: "Batch" })); + + expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.SPAN_TYPE, "batch"); + }); + + it("selecting All Types clears the span_type filter", async () => { + const user = userEvent.setup(); + const { set } = renderFilters({ [LOG_FILTER_IDS.SPAN_TYPE]: "batch" }); + + await user.click(await screen.findByText("Batch")); + await user.click(await screen.findByRole("option", { name: "All Types" })); + + expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.SPAN_TYPE, undefined); + }); + it.each([ ["Cache Hit", "hit"], ["Cache Miss", "miss"], diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx index e97552838da..5059f117944 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx @@ -37,6 +37,14 @@ const CACHE_FILTER_ITEMS = [ { value: "hit", label: "Cache Hit" }, { value: "miss", label: "Cache Miss" }, ] as const; + +const SPAN_TYPE_FILTER_ITEMS = [ + { value: ALL_VALUE, label: "All Types" }, + { value: "llm", label: "LLM" }, + { value: "agent", label: "Agent" }, + { value: "mcp", label: "MCP" }, + { value: "batch", label: "Batch" }, +] as const; const PAGE_SIZE = 50; const SEARCH_INPUT_REASONS: ReadonlySet = new Set(["input-change", "input-clear", "clear-press"]); @@ -328,6 +336,27 @@ export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsF teams={teams} /> + + + +