diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 09d16181c3a..d6352224773 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -3250,12 +3250,23 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): map_finish_reason(completion_response["stop_reason"]), ) - usage: Final = self.calculate_usage( - usage_object=completion_response["usage"], - reasoning_content=reasoning_content, - completion_response=completion_response, - speed=speed, + usage: Final = ( + None + if "usage" not in completion_response or completion_response["usage"] is None + else self.calculate_usage( + usage_object=completion_response["usage"], + reasoning_content=reasoning_content, + completion_response=completion_response, + speed=speed, + ) ) + if usage is None: + # #44535: keep usage absent (None) instead of fabricating a 0/0 + # usage object. Spend tracking and budgets must be able to tell + # "no usage reported" apart from a genuine zero, otherwise a + # response that omits usage would be recorded as free even though + # the upstream consumed tokens. + _hidden_params["usage_missing"] = True setattr(model_response, "usage", usage) model_response.created = int(time.time()) diff --git a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py index f1c38ef4e64..64ce94f9b7e 100644 --- a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -7487,6 +7487,54 @@ def test_chat_dummy_tool_result_for_an_orphaned_tool_call_replays_a_byte_identic assert requests[0]["messages"][2]["content"][0]["type"] == "tool_result" +def test_transform_parsed_response_without_usage(): + """#44535: a non-streaming Anthropic response without a usage object + must not raise KeyError. LiteLLM keeps the usage absent (None) instead + of fabricating a 0/0 usage, so spend tracking and budgets can tell "no + usage reported" apart from a genuine zero, and mark it in hidden params. + + Regression for: KeyError: 'usage' in transform_parsed_response, which + the router mapped to APIConnectionError (HTTP 500), retried + num_retries times, and surfaced as a 500 to the client. + """ + import httpx + + from litellm.types.utils import ModelResponse + + config = AnthropicConfig() + + completion_response = { + "id": "msg_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-6", + "content": [{"type": "text", "text": "Hello there"}], + "stop_reason": "end_turn", + } + + for variant in ("missing", "none"): + response = copy.deepcopy(completion_response) + if variant == "none": + response["usage"] = None + + raw_response = httpx.Response(status_code=200, headers={}) + model_response = ModelResponse() + + result = config.transform_parsed_response( + completion_response=response, + raw_response=raw_response, + model_response=model_response, + json_mode=False, + prefix_prompt=None, + ) + + # Usage stays absent (None), not a fabricated 0/0, and the response + # path is explicitly marked so cost tracking can distinguish "no + # usage reported" from a genuine zero. + assert result.usage is None + assert result._hidden_params.get("usage_missing") is True + + anthropic_chunk_list: Final = [ { "type": "content_block_start",