From 4cefdf4f7252566c77e394e3a1dfaa76a81ba044 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:48:43 -0700 Subject: [PATCH] fix(cost): price the /v1/messages response cost header from the converted message body --- litellm/litellm_core_utils/litellm_logging.py | 45 +++++++++------ .../test_litellm_logging.py | 55 +++++++++++++++++++ 2 files changed, 84 insertions(+), 16 deletions(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index a175ca1c3f6..0bf85f42cbd 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -1656,7 +1656,11 @@ class Logging(LiteLLMLoggingBaseClass): ): return 0.0 - transformed_result: Final = self._generate_content_result_as_model_response(result) + transformed_result: Final = ( + self._anthropic_message_result_as_model_response(result) + if self.call_type == CallTypes.anthropic_messages.value + else self._generate_content_result_as_model_response(result) + ) if transformed_result is not None: result = transformed_result @@ -3878,23 +3882,32 @@ class Logging(LiteLLMLoggingBaseClass): litellm_params={}, ) else: - from litellm.types.llms.anthropic import AnthropicResponse - - pydantic_result: Final = AnthropicResponse.model_validate(result) - import httpx - - result = litellm.AnthropicConfig().transform_parsed_response( - completion_response=pydantic_result.model_dump(), - raw_response=httpx.Response( - status_code=200, - headers={}, - ), - model_response=litellm.ModelResponse(id=provider_response_id), - json_mode=None, - speed=self.optional_params.get("speed") if self.optional_params else None, - ) + result = self._anthropic_message_body_as_model_response(result) return result + def _anthropic_message_result_as_model_response(self, result: object) -> ModelResponse | None: + if not isinstance(result, dict) or result.get("type") != "message": + return None + try: + return self._anthropic_message_body_as_model_response(result) + except Exception as e: # noqa: BLE001 # cost normalization must never break the response path + verbose_logger.debug("anthropic message response cost normalization failed: %s", e) + return None + + def _anthropic_message_body_as_model_response(self, body: object) -> ModelResponse: + import httpx + + from litellm.types.llms.anthropic import AnthropicResponse + + parsed: Final = AnthropicResponse.model_validate(body) + return litellm.AnthropicConfig().transform_parsed_response( + completion_response=parsed.model_dump(), + raw_response=httpx.Response(status_code=200, headers={}), + model_response=litellm.ModelResponse(id=_provider_response_id(body)), + json_mode=None, + speed=self.optional_params.get("speed") if self.optional_params else None, + ) + def _translate_responses_api_response_to_model_response(self, result: ResponsesAPIResponse) -> ModelResponse: """ Convert a Responses API response into a ModelResponse for spend_logs. diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index f1de7390b5b..8a6cfb76cf7 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -2013,6 +2013,61 @@ def test_response_cost_calculator_does_not_transform_non_generate_content_dict() assert not cost +def test_response_cost_calculator_anthropic_message_body_bills_thinking_at_the_reasoning_rate(monkeypatch): + """ + Regression for LIT-6908: the ``x-litellm-response-cost`` header for a non-streaming + /v1/messages call is priced synchronously from the raw Anthropic message body, whose + usage carries no reasoning split, so thinking tokens were billed at the plain output + rate while the spend log (priced from the converted ``ModelResponse``) billed them at + ``output_cost_per_reasoning_token``. The calculator now converts the body first, so + both surfaces agree. + """ + monkeypatch.setitem( + litellm.model_cost, + "lit6908-thinker", + { + "litellm_provider": "deepseek", + "input_cost_per_token": 1e-06, + "output_cost_per_token": 2e-06, + "output_cost_per_reasoning_token": 1e-05, + }, + ) + logging_obj = LitellmLogging( + model="lit6908-thinker", + messages=[{"role": "user", "content": "What is 17 * 23? Answer with just the number."}], + stream=False, + call_type="anthropic_messages", + start_time=time.time(), + litellm_call_id="lit6908", + function_id="lit6908", + ) + logging_obj.model_call_details["custom_llm_provider"] = "deepseek" + logging_obj.optional_params = {} + thinking = "We need answer 17*23=391. Just number." + body = { + "id": "msg_lit6908", + "type": "message", + "role": "assistant", + "model": "lit6908-thinker", + "content": [ + {"type": "thinking", "thinking": thinking, "signature": ""}, + {"type": "text", "text": "391"}, + ], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 97, "output_tokens": 15}, + } + reasoning_tokens = litellm.token_counter(text=thinking, count_response_tokens=True) + assert 0 < reasoning_tokens < 15 + + cost = logging_obj._response_cost_calculator(result=body) + + assert cost == pytest.approx(97 * 1e-06 + (15 - reasoning_tokens) * 2e-06 + reasoning_tokens * 1e-05) + assert cost == pytest.approx( + logging_obj._response_cost_calculator(result=logging_obj._anthropic_messages_logged_response(body)) + ) + + def _file_content_logging_obj(call_type: str) -> LitellmLogging: logging_obj = LitellmLogging( model="gemini-3-flash-preview",