mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
fix(cost): keep the upstream's usage.cost beside litellm's bill and stamp a zero operator price on the stream frame
This commit is contained in:
parent
84042736c3
commit
d2b17a9d26
5 changed files with 105 additions and 61 deletions
|
|
@ -1020,32 +1020,41 @@ def chunk_usage(chunk: object) -> object:
|
|||
PROVIDERS_BILLED_FROM_THEIR_OWN_USAGE_FIELDS: Final = frozenset({"xai", "perplexity", "opencode_go"})
|
||||
|
||||
|
||||
LITELLM_BILLED_USAGE_COST: Final = "litellm_billed_usage_cost"
|
||||
UPSTREAM_USAGE_COST: Final = "upstream_usage_cost"
|
||||
|
||||
|
||||
def mark_usage_cost_as_litellm_bill(response: object, bill: float) -> None:
|
||||
"""Records litellm's bill as the response's ``response_cost`` and notes that ``usage.cost`` now holds that bill,
|
||||
so ``billed_usage_cost`` does not read it back as the upstream's charge on a later pricing pass."""
|
||||
def _numeric(value: object) -> float | None:
|
||||
return float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else None
|
||||
|
||||
|
||||
def stamp_litellm_bill_on_usage(response: object, usage: object, bill: float) -> None:
|
||||
"""Writes litellm's bill into ``usage.cost`` and the hidden ``response_cost`` cache, keeping the upstream's own
|
||||
``usage.cost`` under ``upstream_usage_cost`` so a later pricing pass still bills the upstream's charge instead of
|
||||
reading litellm's bill back as if the upstream had reported it."""
|
||||
hidden: Final[object] = getattr(response, "_hidden_params", None)
|
||||
if isinstance(hidden, dict):
|
||||
hidden_params: Final = cast(dict[str, object], hidden) # cast-ok: hidden params ride on the response as a dict
|
||||
hidden_params.update({"response_cost": bill, LITELLM_BILLED_USAGE_COST: bill})
|
||||
|
||||
|
||||
def _usage_cost_is_litellm_bill(response: object) -> bool:
|
||||
hidden: Final = as_str_mapping(getattr(response, "_hidden_params", None))
|
||||
bill: Final[object] = None if hidden is None else hidden.get(LITELLM_BILLED_USAGE_COST)
|
||||
usage_cost: Final[object] = getattr(_usage_field(response, "usage"), "cost", None)
|
||||
return isinstance(bill, (int, float)) and not isinstance(bill, bool) and bill == usage_cost
|
||||
upstream: Final = (
|
||||
hidden_params[UPSTREAM_USAGE_COST]
|
||||
if UPSTREAM_USAGE_COST in hidden_params
|
||||
else _numeric(getattr(usage, "cost", None))
|
||||
)
|
||||
hidden_params.update({"response_cost": bill, UPSTREAM_USAGE_COST: upstream})
|
||||
setattr(usage, "cost", bill)
|
||||
|
||||
|
||||
def billed_usage_cost(response: object, custom_llm_provider: str | None) -> float | None:
|
||||
"""The charge an OpenAI-compatible upstream reports in ``usage.cost``, skipped for providers billed from their own
|
||||
usage fields and once litellm rewrote ``usage.cost`` to its own bill (the stream builder and the Responses terminal
|
||||
event do)."""
|
||||
if custom_llm_provider in PROVIDERS_BILLED_FROM_THEIR_OWN_USAGE_FIELDS or _usage_cost_is_litellm_bill(response):
|
||||
usage fields. Once litellm wrote its own bill into ``usage.cost`` (the stream builder and the Responses terminal
|
||||
event do), the upstream's figure is read from ``upstream_usage_cost`` instead."""
|
||||
if custom_llm_provider in PROVIDERS_BILLED_FROM_THEIR_OWN_USAGE_FIELDS:
|
||||
return None
|
||||
cost: Final = provider_reported_usage_cost(_usage_field(response, "usage"), custom_llm_provider)
|
||||
hidden: Final = as_str_mapping(getattr(response, "_hidden_params", None))
|
||||
cost: Final = (
|
||||
_numeric(hidden[UPSTREAM_USAGE_COST])
|
||||
if hidden is not None and UPSTREAM_USAGE_COST in hidden
|
||||
else provider_reported_usage_cost(_usage_field(response, "usage"), custom_llm_provider)
|
||||
)
|
||||
return cost if cost is not None and math.isfinite(cost) and cost >= 0 else None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ from litellm._logging import verbose_logger
|
|||
from litellm.litellm_core_utils.core_helpers import (
|
||||
PROVIDERS_BILLED_FROM_THEIR_OWN_USAGE_FIELDS,
|
||||
chunk_usage,
|
||||
mark_usage_cost_as_litellm_bill,
|
||||
provider_reported_usage_cost,
|
||||
stamp_litellm_bill_on_usage,
|
||||
)
|
||||
from litellm.litellm_core_utils.hidden_params import HIDDEN_PARAMS_ATTR, set_hidden_params
|
||||
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import BEDROCK_TRACE_FIELDS, object_items, object_mapping
|
||||
|
|
@ -1425,11 +1425,10 @@ def _stamp_streaming_usage_cost(usage: Usage, response: ModelResponse, logging_o
|
|||
):
|
||||
return
|
||||
computed_cost: Final = logging_obj.response_cost_calculator(result=response)
|
||||
if not isinstance(computed_cost, (int, float)) or computed_cost <= 0:
|
||||
if isinstance(computed_cost, bool) or not isinstance(computed_cost, (int, float)) or computed_cost < 0:
|
||||
return
|
||||
setattr(usage, "cost", computed_cost)
|
||||
_set_hidden_response_cost(response, float(computed_cost))
|
||||
mark_usage_cost_as_litellm_bill(response, float(computed_cost))
|
||||
if computed_cost > 0 or isinstance(getattr(usage, "cost", None), (int, float)):
|
||||
stamp_litellm_bill_on_usage(response, usage, float(computed_cost))
|
||||
|
||||
|
||||
def _field_of(value: object, name: str) -> object:
|
||||
|
|
|
|||
|
|
@ -40,10 +40,10 @@ 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 (
|
||||
mark_usage_cost_as_litellm_bill,
|
||||
process_response_headers,
|
||||
provider_reported_usage_cost,
|
||||
set_response_cost_in_hidden_params,
|
||||
stamp_litellm_bill_on_usage,
|
||||
)
|
||||
from litellm.litellm_core_utils.exception_mapping_utils import with_deployment_api_base
|
||||
from litellm.litellm_core_utils.internal_call_metadata import (
|
||||
|
|
@ -1870,8 +1870,7 @@ def stamp_responses_usage_cost(
|
|||
if isinstance(bill, bool) or not isinstance(bill, (int, float)):
|
||||
return
|
||||
if bill > 0 or isinstance(getattr(usage_obj, "cost", None), (int, float)):
|
||||
setattr(usage_obj, "cost", float(bill))
|
||||
mark_usage_cost_as_litellm_bill(response_obj, float(bill))
|
||||
stamp_litellm_bill_on_usage(response_obj, usage_obj, float(bill))
|
||||
|
||||
|
||||
def opening_item_payload(payload: Mapping[str, object]) -> Mapping[str, object]:
|
||||
|
|
|
|||
|
|
@ -69,9 +69,7 @@ def test_preserve_upstream_non_openai_attributes_keeps_scalar_and_structured_val
|
|||
scalar_attribute="scalar",
|
||||
structured_attribute={"items": ["value"]},
|
||||
)
|
||||
model_response: Final = ModelResponseStream(
|
||||
choices=[StreamingChoices(index=0, delta=Delta(content="hello"))]
|
||||
)
|
||||
model_response: Final = ModelResponseStream(choices=[StreamingChoices(index=0, delta=Delta(content="hello"))])
|
||||
|
||||
preserve_upstream_non_openai_attributes(model_response, original_chunk)
|
||||
|
||||
|
|
@ -262,9 +260,7 @@ class TestMapFinishReasonAnthropic:
|
|||
("content_filtered", "content_filter"),
|
||||
],
|
||||
)
|
||||
def test_anthropic_finish_reasons(
|
||||
self, provider_reason: str, expected: str
|
||||
) -> None:
|
||||
def test_anthropic_finish_reasons(self, provider_reason: str, expected: str) -> None:
|
||||
assert map_finish_reason(provider_reason) == expected
|
||||
|
||||
def test_refusal(self):
|
||||
|
|
@ -336,9 +332,7 @@ class TestMapFinishReasonZhipu:
|
|||
|
||||
|
||||
class TestMapFinishReasonOpenAIPassthrough:
|
||||
@pytest.mark.parametrize(
|
||||
"reason", ["stop", "length", "tool_calls", "function_call", "content_filter"]
|
||||
)
|
||||
@pytest.mark.parametrize("reason", ["stop", "length", "tool_calls", "function_call", "content_filter"])
|
||||
def test_openai_values_pass_through(self, reason):
|
||||
assert map_finish_reason(reason) == reason
|
||||
|
||||
|
|
@ -349,9 +343,7 @@ class TestMapFinishReasonGenericError:
|
|||
assert map_finish_reason("error") == "stop"
|
||||
|
||||
def test_lowercase_error_does_not_warn(self, mocker):
|
||||
warn = mocker.patch(
|
||||
"litellm.litellm_core_utils.core_helpers.verbose_logger.warning"
|
||||
)
|
||||
warn = mocker.patch("litellm.litellm_core_utils.core_helpers.verbose_logger.warning")
|
||||
assert map_finish_reason("error") == "stop"
|
||||
warn.assert_not_called()
|
||||
|
||||
|
|
@ -369,8 +361,7 @@ class TestFinishReasonMapOutputsAreValid:
|
|||
"""Every value in _FINISH_REASON_MAP must be a valid OpenAI finish reason."""
|
||||
for provider_reason, openai_reason in _FINISH_REASON_MAP.items():
|
||||
assert openai_reason in VALID_OPENAI_FINISH_REASONS, (
|
||||
f"Mapped value '{openai_reason}' (from '{provider_reason}') "
|
||||
f"is not a valid OpenAI finish reason"
|
||||
f"Mapped value '{openai_reason}' (from '{provider_reason}') is not a valid OpenAI finish reason"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -380,28 +371,18 @@ class TestRedactNestedMatchAndRegexKeys:
|
|||
"assessments": [
|
||||
{
|
||||
"sensitiveInformationPolicy": {
|
||||
"piiEntities": [
|
||||
{"type": "NAME", "match": "secret-name", "action": "BLOCKED"}
|
||||
]
|
||||
},
|
||||
"wordPolicy": {
|
||||
"customWords": [{"match": "badword", "action": "BLOCKED"}]
|
||||
"piiEntities": [{"type": "NAME", "match": "secret-name", "action": "BLOCKED"}]
|
||||
},
|
||||
"wordPolicy": {"customWords": [{"match": "badword", "action": "BLOCKED"}]},
|
||||
}
|
||||
],
|
||||
"regex": "should-redact-key-named-regex",
|
||||
}
|
||||
out = redact_nested_match_and_regex_keys(payload)
|
||||
assert out["assessments"][0]["sensitiveInformationPolicy"]["piiEntities"][0][
|
||||
"match"
|
||||
] == "[REDACTED]"
|
||||
assert out["assessments"][0]["wordPolicy"]["customWords"][0]["match"] == (
|
||||
"[REDACTED]"
|
||||
)
|
||||
assert out["assessments"][0]["sensitiveInformationPolicy"]["piiEntities"][0]["match"] == "[REDACTED]"
|
||||
assert out["assessments"][0]["wordPolicy"]["customWords"][0]["match"] == ("[REDACTED]")
|
||||
assert out["regex"] == "[REDACTED]"
|
||||
assert payload["assessments"][0]["sensitiveInformationPolicy"]["piiEntities"][
|
||||
0
|
||||
]["match"] == "secret-name"
|
||||
assert payload["assessments"][0]["sensitiveInformationPolicy"]["piiEntities"][0]["match"] == "secret-name"
|
||||
|
||||
def test_passes_through_none_and_str(self):
|
||||
assert redact_nested_match_and_regex_keys(None) is None
|
||||
|
|
@ -564,13 +545,17 @@ class TestIsExpectedClientError:
|
|||
assert is_expected_client_error(over_budget) is True
|
||||
|
||||
litellm_limit = RateLimitError(
|
||||
message="key over rpm", llm_provider="anthropic", model="claude-haiku-4-5",
|
||||
message="key over rpm",
|
||||
llm_provider="anthropic",
|
||||
model="claude-haiku-4-5",
|
||||
category=RateLimitErrorCategory.LITELLM_RATE_LIMIT,
|
||||
)
|
||||
assert is_expected_client_error(litellm_limit) is True
|
||||
|
||||
vendor_limit = RateLimitError(
|
||||
message="rate limited upstream", llm_provider="anthropic", model="claude-haiku-4-5",
|
||||
message="rate limited upstream",
|
||||
llm_provider="anthropic",
|
||||
model="claude-haiku-4-5",
|
||||
category=RateLimitErrorCategory.VENDOR_RATE_LIMIT,
|
||||
)
|
||||
assert is_expected_client_error(vendor_limit) is False
|
||||
|
|
@ -808,7 +793,7 @@ class TestReceiveStampSurvivesASecondMetadataBucket:
|
|||
def test_billed_usage_cost_bills_any_chat_upstream_without_its_own_reader() -> None:
|
||||
from types import SimpleNamespace
|
||||
|
||||
from litellm.litellm_core_utils.core_helpers import billed_usage_cost, mark_usage_cost_as_litellm_bill
|
||||
from litellm.litellm_core_utils.core_helpers import billed_usage_cost
|
||||
from litellm.types.utils import ModelResponse, Usage
|
||||
|
||||
usage = Usage(prompt_tokens=100, completion_tokens=20, total_tokens=120, cost=0.037)
|
||||
|
|
@ -817,8 +802,20 @@ def test_billed_usage_cost_bills_any_chat_upstream_without_its_own_reader() -> N
|
|||
assert billed_usage_cost(chat, "openrouter") == 0.037
|
||||
assert billed_usage_cost(chat, "xai") is None
|
||||
assert billed_usage_cost(SimpleNamespace(usage=usage), "openai") == 0.037
|
||||
priced_by_litellm = ModelResponse(usage=usage)
|
||||
priced_by_litellm._hidden_params["response_cost"] = 0.037
|
||||
assert billed_usage_cost(priced_by_litellm, "openai") == 0.037
|
||||
mark_usage_cost_as_litellm_bill(priced_by_litellm, 0.037)
|
||||
assert billed_usage_cost(priced_by_litellm, "openai") is None
|
||||
|
||||
|
||||
def test_billed_usage_cost_keeps_the_upstream_charge_after_litellm_wrote_its_bill_into_usage_cost() -> None:
|
||||
from litellm.litellm_core_utils.core_helpers import billed_usage_cost, stamp_litellm_bill_on_usage
|
||||
from litellm.types.utils import ModelResponse, Usage
|
||||
|
||||
reported = ModelResponse(usage=Usage(prompt_tokens=100, completion_tokens=20, total_tokens=120, cost=0.037))
|
||||
stamp_litellm_bill_on_usage(reported, reported.usage, 0.0407)
|
||||
stamp_litellm_bill_on_usage(reported, reported.usage, 0.0407)
|
||||
assert reported.usage.cost == 0.0407
|
||||
assert reported._hidden_params["response_cost"] == 0.0407
|
||||
assert billed_usage_cost(reported, "openai") == 0.037
|
||||
|
||||
unreported = ModelResponse(usage=Usage(prompt_tokens=100, completion_tokens=20, total_tokens=120))
|
||||
stamp_litellm_bill_on_usage(unreported, unreported.usage, 0.0015)
|
||||
assert unreported.usage.cost == 0.0015
|
||||
assert billed_usage_cost(unreported, "openai") is None
|
||||
|
|
|
|||
|
|
@ -5347,6 +5347,46 @@ def test_stream_chunk_builder_keeps_provider_reported_usage_cost():
|
|||
assert response._hidden_params["response_cost"] == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_completion_cost_on_a_stamped_stream_still_bills_the_reported_charge():
|
||||
usage_chunk: Final = _stream_builder_text_chunk("gpt-4o", "")
|
||||
usage_chunk.usage = Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15, cost=0.5)
|
||||
chunks: Final = [
|
||||
_stream_builder_text_chunk("gpt-4o", "Hello ", finish_reason="stop"),
|
||||
usage_chunk,
|
||||
]
|
||||
|
||||
response: Final = litellm.stream_chunk_builder(
|
||||
chunks=chunks, messages=[{"role": "user", "content": "hi"}], logging_obj=_stream_builder_logging_obj()
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert litellm.completion_cost(completion_response=response) == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_stream_chunk_builder_stamps_a_zero_operator_price_over_a_reported_cost():
|
||||
logging_obj: Final = _stream_builder_logging_obj()
|
||||
logging_obj.litellm_params = {
|
||||
**logging_obj.litellm_params,
|
||||
"input_cost_per_token": 0.0,
|
||||
"output_cost_per_token": 0.0,
|
||||
}
|
||||
logging_obj.custom_cost_per_token = {"input_cost_per_token": 0.0, "output_cost_per_token": 0.0}
|
||||
usage_chunk: Final = _stream_builder_text_chunk("gpt-4o", "")
|
||||
usage_chunk.usage = Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15, cost=0.5)
|
||||
chunks: Final = [
|
||||
_stream_builder_text_chunk("gpt-4o", "Hello ", finish_reason="stop"),
|
||||
usage_chunk,
|
||||
]
|
||||
|
||||
response: Final = litellm.stream_chunk_builder(
|
||||
chunks=chunks, messages=[{"role": "user", "content": "hi"}], logging_obj=logging_obj
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert getattr(response.usage, "cost", None) == 0.0
|
||||
assert response._hidden_params["response_cost"] == 0.0
|
||||
|
||||
|
||||
def test_stream_chunk_builder_prices_alias_from_openai_sdk_usage_chunk():
|
||||
from openai.types.completion_usage import CompletionUsage
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue