mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
fix(proxy): report /cost/estimate rates from the call that billed them
The estimate looked the reported per-token rates up a second time, with the provider this endpoint resolved rather than the one completion_cost infers. The provider decides whether a token tier threshold is inclusive, so an unrouted xai model sitting exactly on 200k billed at the tier rate and reported the base rate, half of it. completion_cost now hands back the rates its own lines were billed at, and the endpoint reports those. Claude-Session: https://claude.ai/code/session_01RLKy5DMi3XCBUJ37WzfNi1
This commit is contained in:
parent
ba91588b15
commit
36f3ca95d8
7 changed files with 195 additions and 52 deletions
|
|
@ -25,6 +25,7 @@ from litellm.litellm_core_utils.llm_cost_calc.usage_object_transformation import
|
|||
TranscriptionUsageObjectTransformation,
|
||||
)
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import (
|
||||
BilledTokenRates,
|
||||
CostCalculatorUtils,
|
||||
_generic_cost_per_character,
|
||||
_get_regional_uplift_multiplier,
|
||||
|
|
@ -1122,6 +1123,7 @@ def _store_cost_breakdown_in_logging_obj(
|
|||
service_tier: str | None = None,
|
||||
data_residency: str | None = None,
|
||||
vertex_location: str | None = None,
|
||||
billed_token_rates: BilledTokenRates | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Helper function to store cost breakdown in the logging object.
|
||||
|
|
@ -1166,6 +1168,7 @@ def _store_cost_breakdown_in_logging_obj(
|
|||
service_tier=service_tier,
|
||||
data_residency=data_residency,
|
||||
vertex_location=vertex_location,
|
||||
billed_token_rates=billed_token_rates,
|
||||
)
|
||||
|
||||
except Exception as breakdown_error:
|
||||
|
|
@ -1735,6 +1738,7 @@ def completion_cost(
|
|||
_reasoning_cost: float | None = None
|
||||
_cache_read_cost: float | None = None
|
||||
_cache_creation_cost: float | None = None
|
||||
_billed_token_rates: BilledTokenRates | None = None
|
||||
if cost_per_token_usage_object is not None and model:
|
||||
_breakdown_provider: str | None = (
|
||||
custom_llm_provider if isinstance(custom_llm_provider, str) else None
|
||||
|
|
@ -1751,6 +1755,7 @@ def completion_cost(
|
|||
_reasoning_cost = _token_type_breakdown.reasoning_cost
|
||||
_cache_read_cost = _token_type_breakdown.cache_read_cost
|
||||
_cache_creation_cost = _token_type_breakdown.cache_creation_cost
|
||||
_billed_token_rates = _token_type_breakdown.rates
|
||||
_store_cost_breakdown_in_logging_obj(
|
||||
litellm_logging_obj=litellm_logging_obj,
|
||||
prompt_tokens_cost_usd_dollar=prompt_tokens_cost_usd_dollar,
|
||||
|
|
@ -1770,6 +1775,7 @@ def completion_cost(
|
|||
service_tier=service_tier,
|
||||
data_residency=data_residency,
|
||||
vertex_location=vertex_location,
|
||||
billed_token_rates=_billed_token_rates,
|
||||
)
|
||||
|
||||
return _final_cost
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@ if TYPE_CHECKING:
|
|||
from mcp.types import EmbeddedResource, ImageContent, TextContent
|
||||
|
||||
from litellm.integrations.otel.logger import OpenTelemetryV2
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import BilledTokenRates
|
||||
from litellm.llms.base_llm.passthrough.transformation import BasePassthroughConfig
|
||||
try:
|
||||
from litellm_enterprise.enterprise_callbacks.callback_controls import (
|
||||
|
|
@ -581,6 +582,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
|
||||
# Initialize cost breakdown field
|
||||
self.cost_breakdown: CostBreakdown | None = None
|
||||
self.billed_token_rates: BilledTokenRates | None = None
|
||||
|
||||
# Init Caching related details
|
||||
self.caching_details: CachingDetails | None = None
|
||||
|
|
@ -1585,6 +1587,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
service_tier: str | None = None,
|
||||
data_residency: str | None = None,
|
||||
vertex_location: str | None = None,
|
||||
billed_token_rates: "BilledTokenRates | None" = None,
|
||||
) -> None:
|
||||
"""
|
||||
Helper method to store cost breakdown in the logging object.
|
||||
|
|
@ -1604,8 +1607,10 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
service_tier: Tier the costs above were priced on, already resolved
|
||||
data_residency: Region uplift the costs above were priced on, already resolved
|
||||
vertex_location: Vertex AI location the costs above were priced on, already resolved
|
||||
billed_token_rates: Per-token rates the costs above were billed at, already resolved
|
||||
"""
|
||||
|
||||
self.billed_token_rates = billed_token_rates
|
||||
self.cost_breakdown = CostBreakdown(
|
||||
input_cost=input_cost,
|
||||
output_cost=output_cost,
|
||||
|
|
|
|||
|
|
@ -1302,34 +1302,6 @@ def _coerce_token_count(value: object) -> int:
|
|||
return value if isinstance(value, int) and value > 0 else 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TokenTypeCostBreakdown:
|
||||
reasoning_cost: float
|
||||
cache_read_cost: float
|
||||
cache_creation_cost: float
|
||||
|
||||
|
||||
def _reasoning_token_count(usage: Usage) -> int:
|
||||
parsed: Final = (
|
||||
parse_completion_tokens_details(usage)["reasoning_tokens"] if usage.completion_tokens_details is not None else 0
|
||||
)
|
||||
return parsed or _coerce_token_count(getattr(usage, "reasoning_tokens", 0))
|
||||
|
||||
|
||||
def _cache_token_counts(usage: Usage) -> tuple[int, int, CacheCreationTokenDetails | None]:
|
||||
"""(cache read tokens, cache creation tokens, cache creation details): read from prompt_tokens_details
|
||||
first, then the private top-level counters the Usage constructor mirrors cache tokens onto for
|
||||
providers/callers that bypass the details."""
|
||||
parsed: Final = parse_prompt_tokens_details(usage) if usage.prompt_tokens_details is not None else None
|
||||
parsed_read: Final = parsed["cache_hit_tokens"] if parsed is not None else 0
|
||||
parsed_creation: Final = parsed["cache_creation_tokens"] if parsed is not None else 0
|
||||
return (
|
||||
parsed_read or _coerce_token_count(getattr(usage, "_cache_read_input_tokens", 0)),
|
||||
parsed_creation or _coerce_token_count(getattr(usage, "_cache_creation_input_tokens", 0)),
|
||||
parsed["cache_creation_token_details"] if parsed is not None else None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BilledTokenRates:
|
||||
"""Per-token rates one request's usage bills at, after token tiers, off-peak windows and the
|
||||
|
|
@ -1355,6 +1327,37 @@ class BilledTokenRates:
|
|||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TokenTypeCostBreakdown:
|
||||
reasoning_cost: float
|
||||
cache_read_cost: float
|
||||
cache_creation_cost: float
|
||||
rates: BilledTokenRates | None = None
|
||||
"""Rates these lines were billed at, so a caller reporting both cannot resolve them a second,
|
||||
differently-argued way. None when the model's pricing could not be resolved."""
|
||||
|
||||
|
||||
def _reasoning_token_count(usage: Usage) -> int:
|
||||
parsed: Final = (
|
||||
parse_completion_tokens_details(usage)["reasoning_tokens"] if usage.completion_tokens_details is not None else 0
|
||||
)
|
||||
return parsed or _coerce_token_count(getattr(usage, "reasoning_tokens", 0))
|
||||
|
||||
|
||||
def _cache_token_counts(usage: Usage) -> tuple[int, int, CacheCreationTokenDetails | None]:
|
||||
"""(cache read tokens, cache creation tokens, cache creation details): read from prompt_tokens_details
|
||||
first, then the private top-level counters the Usage constructor mirrors cache tokens onto for
|
||||
providers/callers that bypass the details."""
|
||||
parsed: Final = parse_prompt_tokens_details(usage) if usage.prompt_tokens_details is not None else None
|
||||
parsed_read: Final = parsed["cache_hit_tokens"] if parsed is not None else 0
|
||||
parsed_creation: Final = parsed["cache_creation_tokens"] if parsed is not None else 0
|
||||
return (
|
||||
parsed_read or _coerce_token_count(getattr(usage, "_cache_read_input_tokens", 0)),
|
||||
parsed_creation or _coerce_token_count(getattr(usage, "_cache_creation_input_tokens", 0)),
|
||||
parsed["cache_creation_token_details"] if parsed is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def _custom_pricing_rates(custom_cost_per_token: CostPerToken) -> BilledTokenRates:
|
||||
"""Flat custom pricing has no tiers, uplifts or reasoning rate: cache tokens bill at the configured
|
||||
cache rates (else the input rate) and reasoning at the output rate, as _cost_per_token_custom_pricing_helper does."""
|
||||
|
|
@ -1497,6 +1500,7 @@ def get_token_type_cost_breakdown(
|
|||
reasoning_cost=float(_reasoning_token_count(usage)) * rates.output_cost_per_reasoning_token,
|
||||
cache_read_cost=float(cache_read_tokens) * rates.cache_read_input_token_cost,
|
||||
cache_creation_cost=cache_creation_cost,
|
||||
rates=rates,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import litellm
|
|||
from litellm._internal_context import current_billing_time, pinned_billing_time
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.cost_calculator import completion_cost
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import get_billed_token_rates
|
||||
from litellm.proxy._types import (
|
||||
CommonProxyErrors,
|
||||
CostEstimateRequest,
|
||||
|
|
@ -85,9 +84,9 @@ def _extract_custom_pricing(
|
|||
)
|
||||
|
||||
|
||||
def _lookup_model_info(model: str) -> ModelInfo | None:
|
||||
def _lookup_model_info(model: str, custom_llm_provider: str | None = None) -> ModelInfo | None:
|
||||
try:
|
||||
return litellm.get_model_info(model=model)
|
||||
return litellm.get_model_info(model=model, custom_llm_provider=custom_llm_provider)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
|
@ -122,7 +121,7 @@ def _resolve_model_for_cost_lookup(model: str) -> ResolvedCostModel:
|
|||
if resolved_model:
|
||||
verbose_proxy_logger.debug("Resolved model '%s' to '%s' from router", model, resolved_model)
|
||||
custom_cost_per_token: Final = _extract_custom_pricing(
|
||||
litellm_params, model_info, _lookup_model_info(str(resolved_model))
|
||||
litellm_params, model_info, _lookup_model_info(str(resolved_model), provider)
|
||||
)
|
||||
return ResolvedCostModel(str(resolved_model), provider, custom_cost_per_token)
|
||||
except Exception as e:
|
||||
|
|
@ -632,10 +631,9 @@ async def estimate_cost(
|
|||
function_id="cost-estimate",
|
||||
)
|
||||
|
||||
# The totals, the per-token-type lines and the reported rates each resolve pricing on their
|
||||
# own path. Pinning one moment keeps an off-peak window that opens mid-quote from splitting them.
|
||||
billed_at: Final = current_billing_time()
|
||||
with pinned_billing_time(billed_at):
|
||||
# Pinning one moment keeps an off-peak window that opens mid-quote from pricing the totals on
|
||||
# one side of it and the reported rates on the other.
|
||||
with pinned_billing_time(current_billing_time()):
|
||||
# Use completion_cost which handles all the logic including margins/discounts
|
||||
try:
|
||||
cost_per_request: Final = completion_cost(
|
||||
|
|
@ -653,19 +651,16 @@ async def estimate_cost(
|
|||
},
|
||||
)
|
||||
|
||||
rates: Final = get_billed_token_rates(
|
||||
model=resolved_model,
|
||||
custom_llm_provider=resolved_provider,
|
||||
usage=usage,
|
||||
custom_cost_per_token=resolved.custom_cost_per_token,
|
||||
current_time=billed_at,
|
||||
)
|
||||
|
||||
# The rates come back from the pricing call itself rather than a second lookup, so they are the
|
||||
# ones the cost lines above billed at even when completion_cost infers a provider this endpoint
|
||||
# never resolved (an unrouted "xai/grok-4" prices on xai's inclusive tier thresholds; a lookup
|
||||
# here without that provider would report the sub-200k rate for a line billed above it).
|
||||
rates: Final = litellm_logging_obj.billed_token_rates
|
||||
per_request: Final = _cost_lines(cost_per_request, litellm_logging_obj.cost_breakdown)
|
||||
daily: Final = per_request.times(request.num_requests_per_day)
|
||||
monthly: Final = per_request.times(request.num_requests_per_month)
|
||||
|
||||
model_info: Final = _lookup_model_info(resolved_model)
|
||||
model_info: Final = _lookup_model_info(resolved_model, resolved_provider)
|
||||
mapped_provider: Final = model_info.get("litellm_provider") if model_info is not None else None
|
||||
custom_llm_provider: Final = mapped_provider if mapped_provider is not None else resolved_provider
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ from litellm.litellm_core_utils.llm_cost_calc.utils import (
|
|||
CostCalculatorUtils,
|
||||
PromptTokensDetailsResult,
|
||||
TokenRates,
|
||||
TokenTypeCostBreakdown,
|
||||
_calculate_input_cost,
|
||||
_get_token_base_cost,
|
||||
_is_off_peak,
|
||||
|
|
@ -4023,6 +4022,49 @@ def test_a_pinned_billing_time_prices_the_totals_and_the_reported_rates_at_one_m
|
|||
assert peak_completion_cost == pytest.approx(500 * peak_rates.output_cost_per_token)
|
||||
|
||||
|
||||
def test_the_token_type_breakdown_carries_the_rates_it_billed_at(monkeypatch):
|
||||
"""Callers that report both the lines and the rates read the rates off the breakdown rather than
|
||||
resolving them a second time, so the breakdown has to hand back exactly what it billed at."""
|
||||
monkeypatch.setitem(
|
||||
litellm.model_cost,
|
||||
"xai/tiered-model",
|
||||
{
|
||||
"input_cost_per_token": 3e-6,
|
||||
"output_cost_per_token": 15e-6,
|
||||
"cache_read_input_token_cost": 3e-7,
|
||||
"input_cost_per_token_above_200k_tokens": 6e-6,
|
||||
"output_cost_per_token_above_200k_tokens": 3e-5,
|
||||
"cache_read_input_token_cost_above_200k_tokens": 6e-7,
|
||||
"litellm_provider": "xai",
|
||||
"mode": "chat",
|
||||
},
|
||||
)
|
||||
usage = Usage(
|
||||
prompt_tokens=200_000,
|
||||
completion_tokens=1_000,
|
||||
total_tokens=201_000,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=100_000),
|
||||
)
|
||||
|
||||
breakdown = get_token_type_cost_breakdown(model="xai/tiered-model", custom_llm_provider="xai", usage=usage)
|
||||
|
||||
assert breakdown.rates == get_billed_token_rates(
|
||||
model="xai/tiered-model", custom_llm_provider="xai", usage=usage
|
||||
)
|
||||
assert breakdown.rates.cache_read_input_token_cost == pytest.approx(6e-7)
|
||||
assert breakdown.cache_read_cost == pytest.approx(100_000 * breakdown.rates.cache_read_input_token_cost)
|
||||
|
||||
|
||||
def test_the_token_type_breakdown_reports_no_rates_for_an_unpriced_model():
|
||||
usage = Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15)
|
||||
|
||||
breakdown = get_token_type_cost_breakdown(
|
||||
model="no-such-model-anywhere", custom_llm_provider="openai", usage=usage
|
||||
)
|
||||
|
||||
assert breakdown.rates is None
|
||||
|
||||
|
||||
def test_billed_token_rates_are_none_for_an_unpriced_model():
|
||||
usage = Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15)
|
||||
|
||||
|
|
@ -4036,9 +4078,7 @@ def test_token_type_cost_breakdown_zero_without_special_tokens(_local_model_cost
|
|||
model="gpt-4o", custom_llm_provider="openai", usage=usage
|
||||
)
|
||||
|
||||
assert breakdown == TokenTypeCostBreakdown(
|
||||
reasoning_cost=0.0, cache_read_cost=0.0, cache_creation_cost=0.0
|
||||
)
|
||||
assert (breakdown.reasoning_cost, breakdown.cache_read_cost, breakdown.cache_creation_cost) == (0.0, 0.0, 0.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -4110,9 +4150,7 @@ def test_token_type_cost_breakdown_handles_unknown_model_gracefully():
|
|||
completion_tokens_details=CompletionTokensDetailsWrapper(reasoning_tokens=5),
|
||||
),
|
||||
)
|
||||
assert breakdown == TokenTypeCostBreakdown(
|
||||
reasoning_cost=0.0, cache_read_cost=0.0, cache_creation_cost=0.0
|
||||
)
|
||||
assert (breakdown.reasoning_cost, breakdown.cache_read_cost, breakdown.cache_creation_cost) == (0.0, 0.0, 0.0)
|
||||
|
||||
|
||||
def test_token_type_cost_breakdown_applies_regional_uplift(_local_model_cost_map):
|
||||
|
|
|
|||
|
|
@ -1139,6 +1139,45 @@ class TestEstimateCostCacheAndReasoningTokens:
|
|||
assert response.output_cost_per_request == pytest.approx(OUTPUT_TOKENS * response.output_cost_per_token)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unrouted_model_reports_the_rates_of_the_provider_the_calculator_inferred(self, monkeypatch):
|
||||
"""The cost calculator infers a provider this endpoint never resolved, and the provider decides
|
||||
whether a tier threshold is inclusive. xai bills a request sitting exactly on the 200k threshold
|
||||
at the tier rate, so the reported rates have to be the tier's rather than the sub-tier base."""
|
||||
an_xai_model = "xai/tiered-model"
|
||||
monkeypatch.setitem(
|
||||
litellm.model_cost,
|
||||
an_xai_model,
|
||||
{
|
||||
"input_cost_per_token": 3e-6,
|
||||
"output_cost_per_token": 15e-6,
|
||||
"cache_read_input_token_cost": 3e-7,
|
||||
"input_cost_per_token_above_200k_tokens": 6e-6,
|
||||
"output_cost_per_token_above_200k_tokens": 3e-5,
|
||||
"cache_read_input_token_cost_above_200k_tokens": 6e-7,
|
||||
"litellm_provider": "xai",
|
||||
"mode": "chat",
|
||||
},
|
||||
)
|
||||
|
||||
response = await _estimate(
|
||||
None,
|
||||
model=an_xai_model,
|
||||
input_tokens=200_000,
|
||||
cache_read_input_tokens=100_000,
|
||||
output_tokens=1_000,
|
||||
)
|
||||
|
||||
assert response.input_cost_per_token == pytest.approx(6e-6)
|
||||
assert response.output_cost_per_token == pytest.approx(3e-5)
|
||||
assert response.cache_read_input_token_cost == pytest.approx(6e-7)
|
||||
assert response.cache_read_cost_per_request == pytest.approx(100_000 * response.cache_read_input_token_cost)
|
||||
assert response.input_cost_per_request == pytest.approx(
|
||||
100_000 * response.input_cost_per_token + response.cache_read_cost_per_request
|
||||
)
|
||||
assert response.output_cost_per_request == pytest.approx(1_000 * response.output_cost_per_token)
|
||||
|
||||
|
||||
class TestCostEstimateRequestTokenSubsets:
|
||||
def test_cache_tokens_beyond_the_input_tokens_are_rejected(self):
|
||||
with pytest.raises(ValidationError, match="cannot exceed input_tokens"):
|
||||
|
|
|
|||
|
|
@ -3736,6 +3736,62 @@ def test_completion_cost_logs_reasoning_and_cache_breakdown(_local_model_cost_ma
|
|||
assert logging_obj.cost_breakdown["cache_read_cost"] == pytest.approx(100 * 3e-08)
|
||||
|
||||
|
||||
def test_completion_cost_logs_the_rates_it_billed_at(monkeypatch):
|
||||
"""A caller reporting the cost lines beside their per-token rates reads both off this one call.
|
||||
completion_cost infers the provider, and xai's inclusive tier thresholds put a request sitting
|
||||
exactly on 200k at the tier rate, which a lookup made without that inferred provider would miss.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
|
||||
monkeypatch.setitem(
|
||||
litellm.model_cost,
|
||||
"xai/tiered-model",
|
||||
{
|
||||
"input_cost_per_token": 3e-6,
|
||||
"output_cost_per_token": 15e-6,
|
||||
"cache_read_input_token_cost": 3e-7,
|
||||
"input_cost_per_token_above_200k_tokens": 6e-6,
|
||||
"output_cost_per_token_above_200k_tokens": 3e-5,
|
||||
"cache_read_input_token_cost_above_200k_tokens": 6e-7,
|
||||
"litellm_provider": "xai",
|
||||
"mode": "chat",
|
||||
},
|
||||
)
|
||||
logging_obj = Logging(
|
||||
model="xai/tiered-model",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
stream=False,
|
||||
call_type="completion",
|
||||
start_time=datetime.now(),
|
||||
litellm_call_id="billed-rates",
|
||||
function_id="f",
|
||||
)
|
||||
usage = Usage(
|
||||
prompt_tokens=200_000,
|
||||
completion_tokens=1_000,
|
||||
total_tokens=201_000,
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=100_000),
|
||||
)
|
||||
|
||||
litellm.completion_cost(
|
||||
completion_response=ModelResponse(model="xai/tiered-model", usage=usage),
|
||||
model="xai/tiered-model",
|
||||
custom_llm_provider=None,
|
||||
litellm_logging_obj=logging_obj,
|
||||
)
|
||||
|
||||
rates = logging_obj.billed_token_rates
|
||||
assert rates is not None
|
||||
assert rates.input_cost_per_token == pytest.approx(6e-6)
|
||||
assert rates.cache_read_input_token_cost == pytest.approx(6e-7)
|
||||
assert logging_obj.cost_breakdown["cache_read_cost"] == pytest.approx(
|
||||
100_000 * rates.cache_read_input_token_cost
|
||||
)
|
||||
assert logging_obj.cost_breakdown["output_cost"] == pytest.approx(1_000 * rates.output_cost_per_token)
|
||||
|
||||
|
||||
def test_completion_cost_logs_cache_and_reasoning_breakdown_for_custom_pricing():
|
||||
"""
|
||||
A custom-priced deployment bills cache tokens at its custom cache rates, but the
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue