diff --git a/litellm/proxy/spend_tracking/auto_router_benchmarks.py b/litellm/proxy/spend_tracking/auto_router_benchmarks.py index 331f3975e4b..aeee35db0d6 100644 --- a/litellm/proxy/spend_tracking/auto_router_benchmarks.py +++ b/litellm/proxy/spend_tracking/auto_router_benchmarks.py @@ -10,6 +10,7 @@ from __future__ import annotations from collections.abc import Mapping, Sequence from dataclasses import astuple, dataclass, fields from datetime import date, datetime, time, timedelta, timezone +from decimal import Decimal from functools import reduce from typing import TYPE_CHECKING, Final @@ -136,7 +137,16 @@ def _per(numerator: float, denominator: float) -> float: def _number(value: object) -> float: - return float(value) if isinstance(value, (int, float)) else 0.0 + """Postgres aggregates surface through the driver as int, float, Decimal or a numeric + string depending on the column type (SUM over BIGINT and EXTRACT both yield NUMERIC).""" + if isinstance(value, (int, float, Decimal)): + return float(value) + if not isinstance(value, str): + return 0.0 + try: + return float(value) + except ValueError: + return 0.0 def _counters_from_row(row: Mapping[str, object]) -> _Counters: diff --git a/tests/test_litellm/proxy/spend_tracking/test_auto_router_benchmarks.py b/tests/test_litellm/proxy/spend_tracking/test_auto_router_benchmarks.py index 9d8d884f665..a04d65a56bf 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_auto_router_benchmarks.py +++ b/tests/test_litellm/proxy/spend_tracking/test_auto_router_benchmarks.py @@ -6,6 +6,7 @@ is exercised here without a database. import datetime as dt from dataclasses import fields +from decimal import Decimal import pytest @@ -233,6 +234,35 @@ class TestTotals: assert response.groups == () assert response.totals.turns == 0 + def test_decimal_and_string_aggregates_survive_the_driver(self): + """SUM over BIGINT and EXTRACT(EPOCH) yield NUMERIC, which the driver may hand + back as Decimal or a numeric string; neither may read as zero traffic.""" + response = build_response( + rows=[ + _row( + "a", + sessions=Decimal("2"), + turns=Decimal("64"), + total_tokens=Decimal("10000"), + total_session_seconds="7200.5", + ) + ], + start_date=dt.date(2026, 8, 1), + end_date=dt.date(2026, 8, 3), + ) + assert response.totals.turns == 64 + assert response.totals.total_tokens == 10_000 + assert response.totals.avg_tokens_per_session == pytest.approx(5000.0) + assert response.totals.avg_session_seconds == pytest.approx(3600.25) + + def test_a_malformed_aggregate_reads_as_zero_rather_than_failing_the_page(self): + response = build_response( + rows=[_row("a", total_tokens="not-a-number")], + start_date=dt.date(2026, 8, 1), + end_date=dt.date(2026, 8, 3), + ) + assert response.totals.total_tokens == 0 + def test_group_identity_is_carried_through(self): response = build_response( rows=[_row("claude-auto")], start_date=dt.date(2026, 8, 1), end_date=dt.date(2026, 8, 3)