fix(anthropic): handle missing and null response content

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
mateo 2026-10-31 11:51:16 +00:00
parent a713ed6bab
commit ce2b05d80e
2 changed files with 38 additions and 3 deletions

View file

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

View file

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