diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index f03004cec15..89d62b44e3d 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -179,6 +179,14 @@ NATIVE_OUTPUT_MAX_OPTIONAL_PARAMETERS: Final = 24 NATIVE_OUTPUT_MAX_GRAMMAR_BRANCHES: Final = 2048 +class _ResponseContent(BaseModel): + model_config = ConfigDict(frozen=True, from_attributes=True) + content: object = None + + +_RESPONSE_FIELDS: Final = TypeAdapter(_ResponseContent) + + def _tool_type(tool: object) -> str | None: match tool: case {"type": str() as tool_type}: @@ -2593,7 +2601,8 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): web_search_results: list[object] | None = None tool_results: list[_AnthropicToolResultBlock] | None = None compaction_blocks: list[object] | None = None - for idx, content in enumerate(completion_response["content"]): + response_fields: Final = _RESPONSE_FIELDS.validate_python(completion_response) + for idx, content in enumerate(completion_response["content"] if response_fields.content is not None else []): if content["type"] == "text": text_content += content["text"] ## TOOL CALLING @@ -2830,7 +2839,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): # Anthropic doesn't always include tool_search_requests in the usage object if tool_search_requests is None and completion_response is not None: tool_search_count = 0 - for content in completion_response.get("content", []): + for content in completion_response.get("content") or []: if content.get("type") == "server_tool_use": tool_name = content.get("name", "") if "tool_search" in tool_name: @@ -3092,7 +3101,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): _message = json_mode_message model_response.choices[0].message = _message - model_response.hidden_params["original_response"] = completion_response["content"] + model_response.hidden_params["original_response"] = _RESPONSE_FIELDS.validate_python( + completion_response + ).content model_response.choices[0].finish_reason = cast( OpenAIChatCompletionFinishReason, map_finish_reason(completion_response["stop_reason"]), 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 c8a2f783809..0569f7d16cb 100644 --- a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -96,6 +96,30 @@ def test_anthropic_json_mode_non_streaming_mixed_internal_and_user_tools(): assert extra == '{"answer": 42}' +@pytest.mark.parametrize("completion_response", [{}, {"content": None}, {"content": []}]) +def test_calculate_usage_without_content_preserves_reported_tokens(completion_response: dict[str, object]) -> None: + usage: Final = AnthropicConfig().calculate_usage( + usage_object={"input_tokens": 12, "output_tokens": 3}, + reasoning_content=None, + completion_response=completion_response, + ) + assert (usage.prompt_tokens, usage.completion_tokens, usage.total_tokens) == (12, 3, 15) + + +@pytest.mark.parametrize("content_fields", [{}, {"content": None}, {"content": []}]) +def test_transform_response_without_content_preserves_reported_usage(content_fields: dict[str, object]) -> None: + body: Final = { + "id": "msg_empty", + "model": "claude-opus-5-5", + "stop_reason": "end_turn", + "usage": {"input_tokens": 12, "output_tokens": 3}, + **content_fields, + } + response: Final = _transform_message_body(body) + assert response.choices[0].message.content is None + assert (response.usage.prompt_tokens, response.usage.completion_tokens, response.usage.total_tokens) == (12, 3, 15) + + def test_calculate_usage(): """ Do not include cache_creation_input_tokens in the prompt_tokens