mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-07 07:58:09 +00:00
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:
parent
f82002f439
commit
c3071d1663
13 changed files with 203 additions and 19 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue