mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-02 02:11:58 +00:00
fix(spend-logs): rehydrate metadata JSONB text on ui_view_spend_logs
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 <class 'str'>.
This commit is contained in:
parent
ed073d382d
commit
bdf8feb978
2 changed files with 195 additions and 3 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue