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(); + expect(screen.getByText("Classify")).toBeInTheDocument(); + }); + + it("renders nothing for ordinary traffic, which is what makes the tag meaningful", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing for an unrecognized origin rather than labelling it as a classifier call", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ClassifyTag.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ClassifyTag.tsx new file mode 100644 index 00000000000..a466e0e0af3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/ClassifyTag.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/cva.config"; + +export const AUTOROUTER_CLASSIFIER_ORIGIN = "autorouter_classifier"; + +export function ClassifyTag({ origin, className }: { origin?: string | null; className?: string }) { + if (origin !== AUTOROUTER_CLASSIFIER_ORIGIN) return null; + return ( + + Classify + + ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx index 0bcb9050d7d..ee619291f66 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx @@ -3,6 +3,7 @@ import { CloseOutlined, UpOutlined, DownOutlined } from "@ant-design/icons"; import moment from "moment"; import { LogEntry } from "../columns"; import { AutoRouterTag } from "@/components/shared/table_cells"; +import { ClassifyTag } from "./ClassifyTag"; import { getProviderLogoAndName } from "../../provider_info_helpers"; import { DRAWER_HEADER_PADDING, @@ -59,6 +60,7 @@ export function DrawerHeader({ @@ -83,11 +85,13 @@ export function DrawerHeader({ function ModelProviderSection({ model, modelGroup, + internalCallOrigin, providerLogo, providerName, }: { model: string; modelGroup?: string; + internalCallOrigin?: string | null; providerLogo?: string; providerName?: string; }) { @@ -114,6 +118,7 @@ function ModelProviderSection({ )} + ); diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx index cdf8e0b1c60..5b27cfc1d0f 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx @@ -6,6 +6,7 @@ import { LogEntry } from "../columns"; import { AutoRouterIcon, useIsAutoRoutedModelGroup } from "@/components/shared/table_cells"; import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "../constants"; import { getEventDisplayName } from "../utils"; +import { ClassifyTag } from "./ClassifyTag"; import { DrawerHeader } from "./DrawerHeader"; import { useKeyboardNavigation } from "./useKeyboardNavigation"; import { LogDetailContent, GuardrailJumpLink } from "./LogDetailContent"; @@ -78,6 +79,7 @@ function TraceEventRow({ row, isSelected, onClick }: TraceEventRowProps) { {getEventDisplayName(row.call_type, row.model)} +
{durationValue}s