fix(spend): price non-finite provider costs on every route and drain parked NaN batches

Perplexity, OpenRouter pass-through, and Responses streaming now treat a NaN or infinite reported cost as unreported, so the token price bills the request. A daily spend batch parked in the Redis buffer by an older version with a NaN spend now drains as 0 instead of failing every flush. The cost header never carries NaN or Infinity, and a set holding a non-finite float no longer breaks spend log JSON
This commit is contained in:
mateo-berri 2026-10-28 07:21:30 -07:00
parent f82002f439
commit c3071d1663
13 changed files with 203 additions and 19 deletions

View file

@ -72,7 +72,8 @@ def safe_json_structure(
seen.remove(id(obj))
return result
elif isinstance(obj, set):
result = sorted([_serialize(item, seen, depth + 1, key) for item in obj])
members: Final = [_serialize(item, seen, depth + 1, key) for item in obj]
result = sorted(member for member in members if member is not None) + [None] * members.count(None)
seen.remove(id(obj))
return result
elif isinstance(obj, BaseModel):

View file

@ -6,6 +6,7 @@ import httpx
from pydantic import TypeAdapter, ValidationError
import litellm
from litellm.litellm_core_utils.core_helpers import finite_cost
from litellm.litellm_core_utils.decisions_pricing import apply_decisions_pricing
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.litellm_logging import (
@ -91,9 +92,10 @@ class TypeSafePassthroughLoggingHandler:
f"{custom_llm_provider}/{model}" for model in (response_model, request_model) if model is not None
)
pricing: Final = _pricing_for(candidate_model_keys)
reported_cost: Final = finite_cost(usage.cost) if custom_llm_provider == "openrouter" else None
response_cost: Final = (
usage.cost
if custom_llm_provider == "openrouter" and usage.cost is not None
reported_cost
if reported_cost is not None
else input_tokens * pricing.input_cost_per_token + output_tokens * pricing.output_cost_per_token
)
usage_object: Final = Usage(

View file

@ -6,6 +6,7 @@ Helper util for handling perplexity-specific cost calculation
from datetime import datetime
from typing import Final
from litellm.litellm_core_utils.core_helpers import finite_cost
from litellm.litellm_core_utils.llm_cost_calc.utils import TokenRates, apply_off_peak_pricing
from litellm.types.utils import Usage
from litellm.utils import get_model_info
@ -34,10 +35,9 @@ def cost_per_token(model: str, usage: Usage, current_time: datetime | None = Non
total_cost = cost_info.get("total_cost")
elif isinstance(cost_info, (int, float)) and not isinstance(cost_info, bool):
total_cost = float(cost_info)
if total_cost is not None:
# Return total cost as completion_cost (prompt_cost=0) since Perplexity
# doesn't break down by input/output in their cost object
return (0.0, float(total_cost))
reported_cost: Final = None if total_cost is None else finite_cost(float(total_cost))
if reported_cost is not None:
return (0.0, reported_cost)
## FALLBACK: Calculate cost manually if Perplexity doesn't provide it
## GET MODEL INFO

View file

@ -59,6 +59,7 @@ from litellm.litellm_core_utils.bug_report import (
strip_bug_report_notice,
)
from litellm.litellm_core_utils.core_helpers import (
finite_cost,
get_or_create_metadata_bucket,
independent_snapshot,
is_expected_client_error,
@ -1774,7 +1775,9 @@ def _response_cost_header_value(hidden_params: Mapping[str, object]) -> float |
if hidden_params.get("cache_hit") is True:
return 0.0
response_cost: Final = hidden_params.get("response_cost")
return response_cost if isinstance(response_cost, (int, float, str)) and response_cost else ""
if isinstance(response_cost, str):
return response_cost
return finite_cost(response_cost) or ""
def cost_headers(

View file

@ -8,7 +8,7 @@ import asyncio
import json
from collections.abc import Mapping, Sequence
from datetime import datetime
from functools import reduce
from functools import partial, reduce
from itertools import chain
from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, TypeVar, cast
@ -109,6 +109,13 @@ _ValueT = TypeVar("_ValueT")
_DECODED_JSON: Final = TypeAdapter(object)
def _zero_for_non_finite_constant(_constant: str) -> float:
return 0.0
_load_buffered_transaction: Final = partial(json.loads, parse_constant=_zero_for_non_finite_constant)
def _spend_log_json_default(value: object) -> str:
return value.isoformat() if isinstance(value, datetime) else str(value)
@ -825,13 +832,13 @@ class RedisUpdateBuffer:
if slot_result is None:
daily_results.append(None)
else:
list_of_daily = [json.loads(t) for t in slot_result]
list_of_daily = [_load_buffered_transaction(t) for t in slot_result]
aggregated = DailySpendUpdateQueue.get_aggregated_daily_spend_update_transactions(list_of_daily)
daily_results.append(aggregated)
window_spend: Final = (
WindowSpendUpdateQueue.get_aggregated_window_spend_transactions(
tuple(json.loads(transaction) for transaction in raw_results[6])
tuple(_load_buffered_transaction(transaction) for transaction in raw_results[6])
)
if raw_results[6] is not None
else None
@ -880,7 +887,7 @@ class RedisUpdateBuffer:
)
if list_of_transactions is None:
return None
return [json.loads(transaction) for transaction in list_of_transactions]
return [_load_buffered_transaction(transaction) for transaction in list_of_transactions]
async def get_all_daily_spend_update_transactions_from_redis_buffer(
self,
@ -1010,9 +1017,9 @@ class RedisUpdateBuffer:
Parses the list of transactions from Redis
"""
if isinstance(list_of_transactions, list):
return [json.loads(transaction) for transaction in list_of_transactions]
return [_load_buffered_transaction(transaction) for transaction in list_of_transactions]
else:
return [json.loads(list_of_transactions)]
return [_load_buffered_transaction(list_of_transactions)]
@staticmethod
def _combine_list_of_transactions(

View file

@ -39,7 +39,7 @@ from litellm.constants import (
)
from litellm.exceptions import MidStreamFallbackError, RateLimitError
from litellm.litellm_core_utils.asyncify import run_async_function
from litellm.litellm_core_utils.core_helpers import process_response_headers
from litellm.litellm_core_utils.core_helpers import finite_cost, process_response_headers
from litellm.litellm_core_utils.exception_mapping_utils import with_deployment_api_base
from litellm.litellm_core_utils.internal_call_metadata import without_budget_reservation
from litellm.litellm_core_utils.litellm_logging import BilledRoundsSpend
@ -1750,7 +1750,7 @@ def stamp_responses_usage_cost(
if usage_obj is None:
return
response_obj.usage = usage_obj # rebind-ok: the stamped cost has to ride on the response the client receives
if isinstance(getattr(usage_obj, "cost", None), (int, float)):
if finite_cost(getattr(usage_obj, "cost", None)) is not None:
return
try:
cost: Final[float | None] = logging_obj.response_cost_calculator(result=response_obj)

View file

@ -128,8 +128,8 @@ def test_a_non_finite_cost_never_blocks_the_next_requests_spend_rows(
team: Final = scenario.team()
key: Final = scenario.key(user_id=f"non-finite-cost-{uuid4().hex}", team_id=team)
digest: Final = sha256(key.encode()).hexdigest()
for alias in (poisoned_alias, control_alias):
response = candidate.request(
responses: Final = tuple(
candidate.request(
"POST",
"/v1/chat/completions",
{
@ -140,7 +140,9 @@ def test_a_non_finite_cost_never_blocks_the_next_requests_spend_rows(
},
key=key,
)
assert response.status_code == 200, response.text
for alias in (poisoned_alias, control_alias)
)
assert all(response.status_code == 200 for response in responses), [response.text for response in responses]
expected: Final = {poisoned_alias: pytest.approx(poisoned_spend), control_alias: pytest.approx(TOKEN_PRICE)}
spend_logs: Final = _both_requests_rows(

View file

@ -179,6 +179,10 @@ def test_non_finite_floats_are_written_as_json_null(value: float) -> None:
}
def test_a_set_holding_a_non_finite_float_sorts_its_finite_members_and_puts_null_last() -> None:
assert json.loads(safe_dumps({"costs": {2.0, float("inf"), 1.0}})) == {"costs": [1.0, 2.0, None]}
def test_clean_strings_are_not_run_through_replace():
"""Regression for LIT-3910.

View file

@ -9,6 +9,7 @@ import json
import math
import os
from datetime import datetime, timezone
from typing import Final
from unittest.mock import patch
import pytest
@ -140,6 +141,21 @@ class TestPerplexityCostCalculator:
assert prompt_cost == 0.0
assert completion_cost == 0.008
@pytest.mark.parametrize(
"reported_cost",
[float("nan"), float("inf"), {"total_cost": float("nan")}],
ids=["nan", "inf", "nan-total-cost-dict"],
)
def test_a_non_finite_perplexity_cost_is_priced_like_no_reported_cost(self, reported_cost: float | dict):
unreported: Final = Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150)
reported: Final = Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150)
reported.cost = reported_cost
token_priced: Final = perplexity_cost_per_token(model="sonar-pro", usage=unreported)
assert sum(token_priced) > 0
assert perplexity_cost_per_token(model="sonar-pro", usage=reported) == token_priced
OFF_PEAK_MODEL = "sonar-off-peak-test"
OFF_PEAK_WINDOW = "14:00-00:00"
INSIDE_WINDOW = datetime(2026, 9, 3, 17, 25, tzinfo=timezone.utc)

View file

@ -379,6 +379,88 @@ async def test_org_member_spend_is_summed_across_pods_and_restored_on_rpush_fail
assert restored_spend["org_member_list_transactions"] == {member_key: 1.5}
PARKED_DAILY_KEY: Final = "user-1_2025-01-15_api-key-123_gpt-4o_openai_/chat/completions"
def _parked_daily_batch(spend: float) -> str:
return json.dumps(
{
PARKED_DAILY_KEY: {
"spend": spend,
"prompt_tokens": 12,
"completion_tokens": 7,
"api_requests": 1,
"successful_requests": 1,
"failed_requests": 0,
}
},
allow_nan=True,
)
@pytest.mark.asyncio
async def test_a_nan_spend_parked_by_an_older_version_drains_from_the_pipeline_as_zero(
redis_update_buffer: RedisUpdateBuffer, mock_redis_cache: AsyncMock
):
entity_batch: Final = json.dumps({"key_list_transactions": {"hashed-key": float("nan")}}, allow_nan=True)
window_batches: Final = tuple(
json.dumps(
[
{
"entity_type": "key",
"entity_id": "hashed-key",
"window_duration": "30d",
"window_start": "2026-08-01T00:00:00.000000",
"spend": spend,
"started_at": None,
}
],
allow_nan=True,
)
for spend in (float("nan"), 3.0)
)
mock_redis_cache.async_lpop_pipeline = AsyncMock(
return_value=[
[entity_batch],
[_parked_daily_batch(float("nan")), _parked_daily_batch(0.0042)],
None,
None,
None,
None,
list(window_batches),
]
)
(
db_spend,
daily_user,
*_rest,
window_spend,
) = await redis_update_buffer.get_all_transactions_from_redis_buffer_pipeline()
assert db_spend is not None
assert db_spend["key_list_transactions"] == {"hashed-key": 0}
assert daily_user is not None
assert daily_user[PARKED_DAILY_KEY]["spend"] == 0.0042
assert daily_user[PARKED_DAILY_KEY]["api_requests"] == 2
assert window_spend is not None
assert [transaction["spend"] for transaction in window_spend] == [3.0]
@pytest.mark.asyncio
async def test_a_nan_spend_parked_by_an_older_version_drains_from_a_daily_key_as_zero(
redis_update_buffer: RedisUpdateBuffer, mock_redis_cache: AsyncMock
):
mock_redis_cache.async_lpop = AsyncMock(
return_value=[_parked_daily_batch(float("inf")), _parked_daily_batch(0.0042)]
)
daily_tag: Final = await redis_update_buffer.get_all_daily_tag_spend_update_transactions_from_redis_buffer()
assert daily_tag is not None
assert daily_tag[PARKED_DAILY_KEY]["spend"] == 0.0042
@pytest.mark.asyncio
async def test_get_all_transactions_from_redis_buffer_pipeline_no_redis():
"""When redis_cache is None, should return all Nones"""

View file

@ -323,6 +323,35 @@ def test_openrouter_decisions_response_uses_provider_cost_without_cost_map_row(
assert response["kwargs"]["combined_usage_object"].total_tokens == 302
@pytest.mark.parametrize("reported_cost", [float("nan"), float("inf")])
def test_openrouter_non_finite_cost_logs_the_token_price(monkeypatch: pytest.MonkeyPatch, reported_cost: float) -> None:
monkeypatch.setitem(
litellm.model_cost,
"openrouter/typesafe/jev-1.13",
{"input_cost_per_token": 1.0, "output_cost_per_token": 3.0, "litellm_provider": "openrouter", "mode": "chat"},
)
logging_obj: Final = _logging_obj()
result: Final = TypeSafePassthroughLoggingHandler.typesafe_passthrough_handler(
httpx_response=_response(),
response_body={
"model": "typesafe/jev-1.13-20260917",
"usage": {"input_tokens": 2, "output_tokens": 1, "cost": reported_cost},
},
logging_obj=logging_obj,
url_route="https://openrouter.ai/api/alpha/decisions",
result='{"answers": {}}',
start_time=datetime(2000, 1, 1),
end_time=datetime(2000, 1, 1),
cache_hit=False,
request_body={"model": "typesafe/jev-1.13"},
custom_llm_provider="openrouter",
)
assert result["kwargs"]["response_cost"] == pytest.approx(5.0)
assert logging_obj.model_call_details["response_cost"] == pytest.approx(5.0)
def test_success_handler_dispatches_openrouter_to_the_shared_handler():
logging_obj = _logging_obj()
normalized = PassThroughEndpointLogging().normalize_llm_passthrough_logging_payload(

View file

@ -12045,6 +12045,30 @@ class TestResponseCostFromLoggingObjForCacheHits:
)
assert {name: headers.get(name) for name in cost_headers} == dict.fromkeys(cost_headers, expected_header)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("response_cost", "expected_header"),
((float("nan"), None), (float("inf"), None), (0.0042, "0.0042")),
ids=("nan", "inf", "finite"),
)
async def test_a_non_finite_response_cost_sends_no_cost_header(
self, response_cost: float, expected_header: str | None
):
response: Final = litellm.ModelResponse()
response._hidden_params = {"response_cost": response_cost}
headers: Final = await ProxyBaseLLMRequestProcessing.build_litellm_proxy_success_headers_from_llm_response(
response=response,
request_data={"model": "gpt-5.4-mini"},
request=Request({"type": "http", "method": "POST", "headers": []}),
user_api_key_dict=ProxyUserAPIKeyAuth(api_key="sk-test"),
logging_obj=self._logging_obj(),
version=None,
proxy_logging_obj=ProxyLogging(user_api_key_cache=DualCache()),
)
assert headers.get("x-litellm-response-cost") == expected_header
def test_a_fresh_zero_cost_v1_messages_call_still_sends_no_cost_header(self):
logging_obj: Final = self._logging_obj()
logging_obj.model_call_details["response_cost"] = 0.0

View file

@ -864,6 +864,20 @@ def test_stamp_responses_usage_cost_keeps_provider_reported_cost():
logging_obj.response_cost_calculator.assert_not_called()
@pytest.mark.parametrize("reported_cost", [float("nan"), float("inf")])
def test_stamp_responses_usage_cost_replaces_a_non_finite_provider_cost(reported_cost: float):
from litellm.responses.streaming_iterator import stamp_responses_usage_cost
response: Final = _responses_api_response_with_usage()
setattr(response.usage, "cost", reported_cost)
logging_obj: Final = Mock(spec=LiteLLMLoggingObj)
logging_obj.response_cost_calculator.return_value = 0.000704
stamp_responses_usage_cost(response, logging_obj)
assert getattr(response.usage, "cost", None) == pytest.approx(0.000704)
def _unvalidated_response_with_dict_usage(usage: dict) -> ResponsesAPIResponse:
return ResponsesAPIResponse.model_construct(
id="resp_lit7391",