From bdf8feb9781ee8d2035a2e654a13634f8e87adcf Mon Sep 17 00:00:00 2001 From: Chenglun Hu Date: Fri, 5 Jun 2026 00:37:30 +0800 Subject: [PATCH] fix(spend-logs): rehydrate metadata JSONB text on ui_view_spend_logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #29674. `/spend/logs/ui` raw-SQL path returns the JSONB metadata column as a string — prisma's query_raw skips the ORM-layer hydration. The UI reads metadata.status / metadata.error_information as object fields, so provider-failure rows look like successes. Fix: json.loads the metadata field right after query_raw, fall back to {} on malformed JSON. 3 existing error-code/error-message tests called json.loads on response.data[0]["metadata"] — they were leaning on the bug. Updated to read the dict directly. Plus 2 new regression tests (failure metadata roundtrip + invalid-json fallback). Reverting the fix makes both new tests fail with AssertionError: metadata should be dict, got . --- .../spend_management_endpoints.py | 14 ++ .../test_spend_management_endpoints.py | 184 +++++++++++++++++- 2 files changed, 195 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index ca5e0473659..de2cee102f4 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -2137,6 +2137,20 @@ async def ui_view_spend_logs( # noqa: PLR0915 data = await prisma_client.db.query_raw(sql_query, *sql_params) + # query_raw returns the JSONB `metadata` column as a string (the Prisma + # serialiser bypasses the model-layer JSON hydration we get on the ORM + # path). The UI reads `metadata.status` / `metadata.error_information` + # as object fields, so failure rows looked like successes (#29674). + # Re-hydrate to dict here. + for row in data: + if isinstance(row, dict): + md = row.get("metadata") + if isinstance(md, str): + try: + row["metadata"] = json.loads(md) + except (ValueError, TypeError): + row["metadata"] = {} + # Calculate total pages total_pages = (total_records + page_size - 1) // page_size 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 aef91ed3c77..dd7d03b5c34 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 @@ -2628,7 +2628,8 @@ async def test_ui_view_spend_logs_with_error_code(client): assert data["total"] == 1 assert len(data["data"]) == 1 assert data["data"][0]["id"] == "log1" - metadata = json.loads(data["data"][0]["metadata"]) + metadata = data["data"][0]["metadata"] + assert isinstance(metadata, dict) assert "error_information" in metadata assert metadata["error_information"]["error_code"] == "404" finally: @@ -2701,7 +2702,8 @@ async def test_ui_view_spend_logs_with_error_message(client): assert data["total"] == 1 assert len(data["data"]) == 1 assert data["data"][0]["id"] == "log1" - metadata = json.loads(data["data"][0]["metadata"]) + metadata = data["data"][0]["metadata"] + assert isinstance(metadata, dict) assert "error_information" in metadata assert ( "Rate limit exceeded" in metadata["error_information"]["error_message"] @@ -2793,7 +2795,8 @@ async def test_ui_view_spend_logs_with_error_code_and_key_alias(client): assert data["total"] == 1 assert len(data["data"]) == 1 assert data["data"][0]["id"] == "log3" - metadata = json.loads(data["data"][0]["metadata"]) + metadata = data["data"][0]["metadata"] + assert isinstance(metadata, dict) assert "user_api_key_alias" in metadata assert metadata["user_api_key_alias"] == "test-key-1" assert "error_information" in metadata @@ -3605,3 +3608,178 @@ async def test_spend_user_fn_strips_password_field(client, monkeypatch): assert "password" not in body[0] finally: app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +@pytest.mark.asyncio +async def test_ui_view_spend_logs_rehydrates_metadata_jsonb_text(client, monkeypatch): + """ + Regression for #29674: query_raw returns the JSONB `metadata` column as a + string, so failure rows (status="failure", error_information.error_code=...) + looked like successes at the UI layer because metadata.status was the + string ".status" attribute lookup on a str. The endpoint must re-hydrate + `metadata` to a dict before returning. + """ + failure_metadata = { + "status": "failure", + "error_information": { + "error_code": "403", + "error_message": "Forbidden by upstream", + }, + "user_api_key_alias": "alias-1", + } + + raw_row = { + "request_id": "req-failure-1", + "call_type": "completion", + "api_key": "hashed-key", + "spend": 0.0, + "total_tokens": 0, + "prompt_tokens": 0, + "completion_tokens": 0, + "startTime": "2025-01-01T00:00:00Z", + "endTime": "2025-01-01T00:00:01Z", + "completionStartTime": None, + "model": "gpt-4o", + "model_id": None, + "model_group": None, + "custom_llm_provider": "openai", + "api_base": None, + "user": "u", + "metadata": json.dumps(failure_metadata), # JSONB column comes back as str + "cache_hit": None, + "cache_key": None, + "request_tags": None, + "team_id": None, + "organization_id": None, + "end_user": None, + "requester_ip_address": None, + "session_id": None, + "status": "failure", + "mcp_namespaced_tool_name": None, + "agent_id": None, + "request_duration_ms": 1000, + } + + async def mock_count(*args, **kwargs): + return 1 + + async def mock_query_raw(sql_query, *params): + return [raw_row] + + class MockPrismaClient: + def __init__(self): + self.db = MagicMock() + self.db.litellm_spendlogs = MagicMock() + self.db.litellm_spendlogs.count = AsyncMock(side_effect=mock_count) + self.db.query_raw = AsyncMock(side_effect=mock_query_raw) + + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", MockPrismaClient()) + 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: + response = client.get( + "/spend/logs/ui", + params={ + "start_date": "2024-12-25 00:00:00", + "end_date": "2025-01-02 23:59:59", + }, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["data"], "expected one row in data" + row = body["data"][0] + md = row["metadata"] + # The bug had metadata returned as a JSON string; the fix re-hydrates + # it so the dashboard's metadata.status / metadata.error_information + # accessors work. + assert isinstance(md, dict), f"metadata should be dict, got {type(md)}" + assert md["status"] == "failure" + assert md["error_information"]["error_code"] == "403" + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +@pytest.mark.asyncio +async def test_ui_view_spend_logs_metadata_invalid_json_falls_back_to_empty_dict( + client, monkeypatch +): + """ + Defensive: if `metadata` is somehow not valid JSON, fall back to {} rather + than 500-ing the whole UI page. + """ + raw_row = { + "request_id": "req-bad-json", + "call_type": "completion", + "api_key": "hashed-key", + "spend": 0.0, + "total_tokens": 0, + "prompt_tokens": 0, + "completion_tokens": 0, + "startTime": "2025-01-01T00:00:00Z", + "endTime": "2025-01-01T00:00:01Z", + "completionStartTime": None, + "model": "gpt-4o", + "model_id": None, + "model_group": None, + "custom_llm_provider": "openai", + "api_base": None, + "user": "u", + "metadata": "{not-json", + "cache_hit": None, + "cache_key": None, + "request_tags": None, + "team_id": None, + "organization_id": None, + "end_user": None, + "requester_ip_address": None, + "session_id": None, + "status": "success", + "mcp_namespaced_tool_name": None, + "agent_id": None, + "request_duration_ms": 500, + } + + async def mock_count(*args, **kwargs): + return 1 + + async def mock_query_raw(sql_query, *params): + return [raw_row] + + class MockPrismaClient: + def __init__(self): + self.db = MagicMock() + self.db.litellm_spendlogs = MagicMock() + self.db.litellm_spendlogs.count = AsyncMock(side_effect=mock_count) + self.db.query_raw = AsyncMock(side_effect=mock_query_raw) + + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", MockPrismaClient()) + 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: + response = client.get( + "/spend/logs/ui", + params={ + "start_date": "2024-12-25 00:00:00", + "end_date": "2025-01-02 23:59:59", + }, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["data"] + assert body["data"][0]["metadata"] == {} + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None)