fix(cost): price the /v1/messages response cost header from the converted message body

This commit is contained in:
mateo-berri 2026-09-05 00:48:43 -07:00
parent d024c2d980
commit 4cefdf4f72
2 changed files with 84 additions and 16 deletions

View file

@ -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.

View file

@ -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",