diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py
index 42788227acc..f7e429ddb31 100644
--- a/litellm/proxy/spend_tracking/spend_management_endpoints.py
+++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py
@@ -11,6 +11,7 @@ from typing import (
Literal,
Mapping,
NamedTuple,
+ Sequence,
Union,
)
@@ -2025,19 +2026,7 @@ async def ui_view_spend_logs(
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"] = {}
+ _hydrate_spend_log_metadata(data)
# Calculate total pages
total_pages = (total_records + page_size - 1) // page_size
@@ -2078,6 +2067,27 @@ def _spend_log_field_has_content(value: Union[str, list, dict] | None) -> bool:
return True
+def _hydrate_spend_log_metadata(rows: Sequence[Any]) -> None:
+ """Re-hydrate the JSONB ``metadata`` column returned by ``query_raw`` as a string.
+
+ The Prisma serialiser bypasses the model-layer JSON hydration we get on the ORM
+ path, while the UI reads ``metadata.status`` / ``metadata.error_information`` /
+ ``metadata.internal_call_origin`` as object fields. Property access on a string
+ is silently undefined, so failure rows looked like successes (#29674). Every
+ ``query_raw`` reader of this column goes through here so a new one cannot
+ reintroduce that.
+ """
+ for row in rows:
+ if not isinstance(row, dict):
+ continue
+ md = row.get("metadata")
+ if isinstance(md, str):
+ try:
+ row["metadata"] = json.loads(md)
+ except (ValueError, TypeError):
+ row["metadata"] = {}
+
+
def _cold_storage_object_key_from_metadata(
metadata: Union[str, dict] | None,
) -> str | None:
@@ -3361,6 +3371,7 @@ async def ui_view_session_spend_logs(
LIMIT $2 OFFSET $3
"""
result = await prisma_client.db.query_raw(sql_query, session_id, page_size, skip, *scope_params)
+ _hydrate_spend_log_metadata(result)
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 aa20c3f6ed4..f6216d1646e 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
@@ -1582,6 +1582,47 @@ async def test_ui_view_session_spend_logs_pagination(client, monkeypatch):
app.dependency_overrides.pop(ps.user_api_key_auth, None)
+@pytest.mark.asyncio
+async def test_ui_view_session_spend_logs_rehydrates_metadata_jsonb_text(client, monkeypatch):
+ """The session sidebar reads metadata fields as object properties, so this endpoint
+ must re-hydrate the JSONB column that query_raw hands back as a string, exactly as
+ /spend/logs/ui does (#29674). Property access on a string is silently undefined,
+ so a row's origin, status and error information all read as absent without this.
+ """
+ raw_row = {
+ "request_id": "req-classifier-1",
+ "session_id": "session-123",
+ "startTime": "2024-01-01T00:00:00Z",
+ "metadata": json.dumps({"internal_call_origin": "autorouter_classifier", "status": "success"}),
+ }
+
+ class MockDB:
+ async def count(self, *args, **kwargs):
+ return 1
+
+ async def query_raw(self, sql_query, session_id, page_size, skip, *scope_params):
+ return [dict(raw_row)]
+
+ class MockPrismaClient:
+ def __init__(self):
+ self.db = MockDB()
+ self.db.litellm_spendlogs = self.db
+
+ monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", MockPrismaClient())
+ 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/session/ui", params={"session_id": "session-123"})
+ assert response.status_code == 200
+ row = response.json()["data"][0]
+ assert isinstance(row["metadata"], dict)
+ assert row["metadata"]["internal_call_origin"] == "autorouter_classifier"
+ finally:
+ app.dependency_overrides.pop(ps.user_api_key_auth, None)
+
+
@pytest.mark.asyncio
async def test_ui_view_session_spend_logs_scopes_non_admin_to_own_logs(client, monkeypatch):
own_log = {
diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ClassifyTag.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ClassifyTag.test.tsx
new file mode 100644
index 00000000000..dbe751cb9e8
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ClassifyTag.test.tsx
@@ -0,0 +1,20 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { ClassifyTag } from "./ClassifyTag";
+
+describe("ClassifyTag", () => {
+ it("renders for an auto-router classifier row", () => {
+ render(