fix(cost): keep mid-stream pricing from leaking a breakdown into the spend log

Pricing a frame through the request's own logging object is what makes custom
deployment pricing work, but _response_cost_calculator does not only return a
number. It also stamps cost_breakdown onto the live logging object, and on a
pricing failure it writes response_cost_failure_debug_information into
model_call_details.

On an ordinary proxy stream that is harmless, because the success handler
recomputes cost_breakdown at end of stream and overwrites whatever the frames
left behind. The pass-through handlers are the problem: they compute their final
cost with a bare completion_cost call and never touch cost_breakdown again, so a
breakdown derived from one mid-stream frame would survive to the end and land in
the spend log's metadata. response_cost itself is unaffected either way, so this
was a reporting surface bug rather than a billing one, but the spend row would
have gone from null to a populated breakdown for a partial frame.

Snapshot both writes and put them back once the cost is read, so pricing a frame
stays a read as far as the rest of the request is concerned. The returned cost is
unchanged, so nothing about the injected usage.cost moves.
This commit is contained in:
mateo-berri 2026-08-20 19:01:16 -07:00
parent 101ef7e167
commit 655d10775c
2 changed files with 77 additions and 0 deletions

View file

@ -3560,10 +3560,27 @@ class ProxyBaseLLMRequestProcessing:
def _logging_obj_cost_or_none(
model_response: ModelResponse, litellm_logging_obj: LiteLLMLoggingObj
) -> float | None:
# Pricing a frame stamps cost_breakdown and, on failure, the cost-failure debug key onto
# the live logging object. The pass-through handlers never recompute either one, so a
# frame-derived breakdown would outlive the stream and land in the spend log. Snapshot
# both and put them back, so pricing here stays a read as far as the request is concerned
breakdown_before: Final = getattr(litellm_logging_obj, "cost_breakdown", None)
call_details: Final = getattr(litellm_logging_obj, "model_call_details", None)
debug_key: Final = "response_cost_failure_debug_information"
debug_missing: Final = object()
debug_before: Final = call_details.get(debug_key, debug_missing) if isinstance(call_details, dict) else None
try:
cost: Final = litellm_logging_obj._response_cost_calculator(result=model_response) # pyright: ignore[reportPrivateUsage] # reuse the call's own cost calc for pricing parity with the logging callback
except Exception: # noqa: BLE001 # a pricing failure falls back to model-name pricing instead of breaking the stream
return None
finally:
if hasattr(litellm_logging_obj, "cost_breakdown"):
litellm_logging_obj.cost_breakdown = breakdown_before
if isinstance(call_details, dict):
if debug_before is debug_missing:
call_details.pop(debug_key, None)
else:
call_details[debug_key] = debug_before
return float(cost) if isinstance(cost, (int, float)) and not isinstance(cost, bool) else None
@staticmethod

View file

@ -6222,6 +6222,66 @@ class TestInjectCostIntoUsageDict:
+ 8 * pricing["output_cost_per_token"]
)
def test_pricing_a_frame_leaves_the_real_logging_obj_unchanged(self):
"""Pricing runs against the live logging object, and the pass-through handlers never
recompute cost_breakdown, so a frame-derived breakdown would reach the spend log."""
from litellm.litellm_core_utils.litellm_logging import (
Logging as LiteLLMLoggingObj,
)
from litellm.types.utils import ModelResponse, Usage
logging_obj = LiteLLMLoggingObj(
model="claude-haiku-4-5",
messages=[{"role": "user", "content": "test"}],
stream=True,
call_type="completion",
start_time=None,
litellm_call_id="lit4902-breakdown-test",
function_id="lit4902-breakdown-test",
)
logging_obj.update_environment_variables(litellm_params={}, optional_params={})
logging_obj.model_call_details["custom_llm_provider"] = "anthropic"
assert logging_obj.cost_breakdown is None
model_response = ModelResponse(
usage=Usage(prompt_tokens=3216, completion_tokens=8, total_tokens=3224)
)
cost = ProxyBaseLLMRequestProcessing._logging_obj_cost_or_none(model_response, logging_obj)
assert cost is not None and cost > 0
assert logging_obj.cost_breakdown is None
assert "response_cost_failure_debug_information" not in logging_obj.model_call_details
def test_pricing_a_frame_restores_a_breakdown_the_request_already_had(self):
from litellm.litellm_core_utils.litellm_logging import (
Logging as LiteLLMLoggingObj,
)
from litellm.types.utils import ModelResponse, Usage
logging_obj = LiteLLMLoggingObj(
model="claude-haiku-4-5",
messages=[{"role": "user", "content": "test"}],
stream=True,
call_type="completion",
start_time=None,
litellm_call_id="lit4902-breakdown-restore",
function_id="lit4902-breakdown-restore",
)
logging_obj.update_environment_variables(litellm_params={}, optional_params={})
logging_obj.model_call_details["custom_llm_provider"] = "anthropic"
logging_obj.set_cost_breakdown(
input_cost=0.5, output_cost=0.25, total_cost=0.75, cost_for_built_in_tools_cost_usd_dollar=0.0
)
existing = logging_obj.cost_breakdown
model_response = ModelResponse(
usage=Usage(prompt_tokens=3216, completion_tokens=8, total_tokens=3224)
)
ProxyBaseLLMRequestProcessing._logging_obj_cost_or_none(model_response, logging_obj)
assert logging_obj.cost_breakdown is existing
assert logging_obj.cost_breakdown["total_cost"] == 0.75
def test_openai_chunk_prices_through_the_logging_obj_so_custom_pricing_applies(self):
"""The chat.completion.chunk path rides the same pricer, so a discounted deployment
streaming /v1/chat/completions gets its negotiated price instead of sticker."""