From a510654736c2d1e4d5e075fcc1ad1640ba06d977 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 22 Dec 2025 17:46:25 -0800 Subject: [PATCH] Allow Error code filtering on spend logs endpoint --- .../spend_management_endpoints.py | 23 ++- .../test_spend_management_endpoints.py | 188 ++++++++++++++++++ 2 files changed, 209 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index ca7e327dbd9..7271a7d7586 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -1678,6 +1678,9 @@ async def ui_view_spend_logs( # noqa: PLR0915 end_user: Optional[str] = fastapi.Query( default=None, description="Filter logs by end user" ), + error_code: Optional[str] = fastapi.Query( + default=None, description="Filter logs by error code (e.g., '404', '500')" + ), ): """ View spend logs with pagination support. @@ -1757,11 +1760,27 @@ async def ui_view_spend_logs( # noqa: PLR0915 if model is not None: where_conditions["model"] = model + # Build metadata filters + metadata_filters = [] if key_alias is not None: - where_conditions["metadata"] = { + metadata_filters.append({ "path": ["user_api_key_alias"], "string_contains": key_alias, - } + }) + + if error_code is not None: + metadata_filters.append({ + "path": ["error_information", "error_code"], + "equals": error_code, + }) + + if metadata_filters: + if len(metadata_filters) == 1: + where_conditions["metadata"] = metadata_filters[0] + else: + where_conditions["AND"] = where_conditions.get("AND", []) + [ + {"metadata": filter_cond} for filter_cond in metadata_filters + ] if end_user is not None: where_conditions["end_user"] = end_user 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 e08f2ad98dd..99c3fa034d6 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 @@ -1857,3 +1857,191 @@ async def test_view_spend_logs_with_date_range_summarized(client, monkeypatch): assert "spend" in data[0] assert "users" in data[0] assert "models" in data[0] + + +@pytest.mark.asyncio +async def test_ui_view_spend_logs_with_error_code(client): + """Test filtering spend logs by error code""" + mock_spend_logs = [ + { + "id": "log1", + "request_id": "req1", + "api_key": "sk-test-key", + "user": "test_user_1", + "team_id": "team1", + "spend": 0.05, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-3.5-turbo", + "metadata": '{"error_information": {"error_code": "404"}}', + }, + { + "id": "log2", + "request_id": "req2", + "api_key": "sk-test-key", + "user": "test_user_2", + "team_id": "team1", + "spend": 0.10, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + "metadata": '{"error_information": {"error_code": "500"}}', + }, + ] + + with patch.object(ps, "prisma_client") as mock_prisma: + # Mock the find_many method to return filtered results + async def mock_find_many(*args, **kwargs): + where_conditions = kwargs.get("where", {}) + if "metadata" in where_conditions: + metadata_filter = where_conditions["metadata"] + if metadata_filter.get("path") == ["error_information", "error_code"]: + error_code = metadata_filter.get("equals") + if error_code == "404": + return [mock_spend_logs[0]] + elif error_code == "500": + return [mock_spend_logs[1]] + return mock_spend_logs + + async def mock_count(*args, **kwargs): + where_conditions = kwargs.get("where", {}) + if "metadata" in where_conditions: + metadata_filter = where_conditions["metadata"] + if metadata_filter.get("path") == ["error_information", "error_code"]: + error_code = metadata_filter.get("equals") + if error_code == "404": + return 1 + elif error_code == "500": + return 1 + return len(mock_spend_logs) + + mock_prisma.db.litellm_spendlogs.find_many = mock_find_many + mock_prisma.db.litellm_spendlogs.count = mock_count + + start_date = ( + datetime.datetime.now(timezone.utc) - datetime.timedelta(days=7) + ).strftime("%Y-%m-%d %H:%M:%S") + end_date = datetime.datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + response = client.get( + "/spend/logs/ui", + params={ + "error_code": "404", + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert len(data["data"]) == 1 + assert data["data"][0]["id"] == "log1" + metadata = json.loads(data["data"][0]["metadata"]) + assert "error_information" in metadata + assert metadata["error_information"]["error_code"] == "404" + + +@pytest.mark.asyncio +async def test_ui_view_spend_logs_with_error_code_and_key_alias(client): + """Test merging error_code and key_alias filters with AND logic""" + mock_spend_logs = [ + { + "id": "log1", + "request_id": "req1", + "api_key": "sk-test-key", + "user": "test_user_1", + "team_id": "team1", + "spend": 0.05, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-3.5-turbo", + "metadata": '{"user_api_key_alias": "test-key-1", "error_information": {"error_code": "404"}}', + }, + { + "id": "log2", + "request_id": "req2", + "api_key": "sk-test-key", + "user": "test_user_2", + "team_id": "team1", + "spend": 0.10, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + "metadata": '{"user_api_key_alias": "test-key-2", "error_information": {"error_code": "500"}}', + }, + { + "id": "log3", + "request_id": "req3", + "api_key": "sk-test-key", + "user": "test_user_3", + "team_id": "team1", + "spend": 0.15, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + "metadata": '{"user_api_key_alias": "test-key-1", "error_information": {"error_code": "500"}}', + }, + ] + + with patch.object(ps, "prisma_client") as mock_prisma: + # Mock the find_many method to handle AND conditions + async def mock_find_many(*args, **kwargs): + where_conditions = kwargs.get("where", {}) + if "AND" in where_conditions: + key_alias_filter = None + error_code_filter = None + for condition in where_conditions["AND"]: + if "metadata" in condition: + metadata_filter = condition["metadata"] + if metadata_filter.get("path") == ["user_api_key_alias"]: + key_alias_filter = metadata_filter.get("string_contains") + elif metadata_filter.get("path") == ["error_information", "error_code"]: + error_code_filter = metadata_filter.get("equals") + + if key_alias_filter == "test-key-1" and error_code_filter == "500": + return [mock_spend_logs[2]] # Only log3 matches both conditions + return mock_spend_logs + + async def mock_count(*args, **kwargs): + where_conditions = kwargs.get("where", {}) + if "AND" in where_conditions: + key_alias_filter = None + error_code_filter = None + for condition in where_conditions["AND"]: + if "metadata" in condition: + metadata_filter = condition["metadata"] + if metadata_filter.get("path") == ["user_api_key_alias"]: + key_alias_filter = metadata_filter.get("string_contains") + elif metadata_filter.get("path") == ["error_information", "error_code"]: + error_code_filter = metadata_filter.get("equals") + + if key_alias_filter == "test-key-1" and error_code_filter == "500": + return 1 + return len(mock_spend_logs) + + mock_prisma.db.litellm_spendlogs.find_many = mock_find_many + mock_prisma.db.litellm_spendlogs.count = mock_count + + start_date = ( + datetime.datetime.now(timezone.utc) - datetime.timedelta(days=7) + ).strftime("%Y-%m-%d %H:%M:%S") + end_date = datetime.datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + response = client.get( + "/spend/logs/ui", + params={ + "error_code": "500", + "key_alias": "test-key-1", + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert len(data["data"]) == 1 + assert data["data"][0]["id"] == "log3" + metadata = json.loads(data["data"][0]["metadata"]) + assert "user_api_key_alias" in metadata + assert metadata["user_api_key_alias"] == "test-key-1" + assert "error_information" in metadata + assert metadata["error_information"]["error_code"] == "500"