fix(anthropic): drop blank text beside an answered native tool_use instead of sending the placeholder

The empty-text sanitizer only counted tool_calls, thinking, server_tool_use and tool result blocks as
content that survives the assistant converter, so a blank text part next to a replayed native tool_use
became the protocol placeholder and reached Claude as assistant text. A native tool_use now counts when
the next turn answers it, the same check the converter uses before keeping it, so the blank part is
dropped there and an unanswered tool_use still gets the placeholder, since the converter drops it and
the turn would otherwise be empty
This commit is contained in:
mateo-berri 2026-10-31 14:55:32 -07:00
parent 1fecc7a8f8
commit 488f3d295e
3 changed files with 61 additions and 13 deletions

View file

@ -2598,13 +2598,14 @@ def _with_placeholder_text(block: object) -> object:
return {**fields, "text": _EMPTY_TEXT_PLACEHOLDER}
def _survives_assistant_conversion(block: object) -> bool:
def _survives_assistant_conversion(block: object, answered: Callable[[], frozenset[str]]) -> bool:
"""Whether ``anthropic_messages_pt`` keeps this list-content block of an assistant turn.
Mirrors the assistant converter: a ``thinking`` / ``redacted_thinking`` block is kept
unless it is unsignable (a signed block is kept even with empty text), and
``server_tool_use`` and ``*_tool_result`` blocks pass through. A redacted block with
blank data is dropped under ``drop_params``, which is not known here, so it never counts.
unless it is unsignable (a signed block is kept even with empty text),
``server_tool_use`` and ``*_tool_result`` blocks pass through, and a native ``tool_use``
is kept when the next turn answers it. A redacted block with blank data is dropped under
``drop_params``, which is not known here, so it never counts.
"""
fields: Final = _dict_block(block)
if fields is None:
@ -2614,6 +2615,8 @@ def _survives_assistant_conversion(block: object) -> bool:
match fields:
case {"type": "server_tool_use"}:
return True
case {"type": "tool_use"}:
return tool_use_is_answered(native_tool_use_id(fields), answered())
case {"type": str() as block_type}:
return block_type.endswith("_tool_result")
case _:
@ -2635,12 +2638,16 @@ def _tool_call_survives_conversion(tool_call: object) -> bool:
return True
def _carries_non_text_content(message: AllMessageValues) -> bool:
def _carries_non_text_content(message: AllMessageValues, answered: Callable[[], frozenset[str]]) -> bool:
if any(_tool_call_survives_conversion(tool_call) for tool_call in parts_of(message_field(message, "tool_calls"))):
return True
content: Final = message_field(message, "content")
blocks: Final = parts_of(content)
return isinstance(content, list) and any(_survives_assistant_conversion(block) for block in blocks)
return isinstance(content, list) and any(_survives_assistant_conversion(block, answered) for block in blocks)
def _answered_at(answered_by_index: Callable[[], Mapping[int, frozenset[str]]], index: int) -> frozenset[str]:
return answered_by_index().get(index, frozenset())
def _with_content(message: AllMessageValues, content: object, action: str) -> AllMessageValues:
@ -2651,6 +2658,7 @@ def _with_content(message: AllMessageValues, content: object, action: str) -> Al
def _sanitize_empty_text_content(
message: AllMessageValues,
answered: Callable[[], frozenset[str]],
) -> AllMessageValues:
"""
Case C: Sanitize empty text content
@ -2658,8 +2666,8 @@ def _sanitize_empty_text_content(
Anthropic rejects empty text blocks, so empty or whitespace-only text must never be
forwarded verbatim. What replaces it depends on what else the turn carries.
Assistant turns that already carry substantive content (``tool_calls``, or non-text
blocks such as ``server_tool_use``) get the empty text dropped. Anthropic accepts an
Assistant turns that already carry substantive content (``tool_calls``, an answered
``tool_use``, or other non-text blocks such as ``server_tool_use``) get the empty text dropped. Anthropic accepts an
assistant turn whose content is just ``tool_use``, so there is nothing to substitute, and
substituting anyway leaks a protocol diagnostic into the conversation as though the
assistant had said it, which the model then echoes on later turns.
@ -2674,7 +2682,9 @@ def _sanitize_empty_text_content(
return message
content: Final = message_field(message, "content")
drop_empty_text: Final = message_field(message, "role") == "assistant" and _carries_non_text_content(message)
drop_empty_text: Final = message_field(message, "role") == "assistant" and _carries_non_text_content(
message, answered
)
if isinstance(content, str):
if content.strip():
@ -2960,6 +2970,7 @@ def _pair_tool_calls_with_results(messages: Sequence[AllMessageValues]) -> tuple
latest_assistant_tool_call_ids: Final = tuple(
itertools.accumulate(messages, _tool_call_ids_of_the_latest_assistant, initial=frozenset[object]())
)
answered_by_index: Final = cache(partial(answered_ids_by_index, messages))
sanitized_messages: list[AllMessageValues] = []
i = 0
@ -2967,7 +2978,7 @@ def _pair_tool_calls_with_results(messages: Sequence[AllMessageValues]) -> tuple
current_message = messages[i]
# Case C: Sanitize empty text content
current_message = _sanitize_empty_text_content(current_message)
current_message = _sanitize_empty_text_content(current_message, partial(_answered_at, answered_by_index, i))
# Case A: Check if assistant message has tool_calls without following tool results
if current_message.get("role") == "assistant":
@ -3203,7 +3214,11 @@ def anthropic_messages_pt(
# request will always 400 otherwise. The richer tool-call sanitization
# (Cases A/B/D in `sanitize_messages_for_tool_calling`) remains gated on
# `modify_params` because it actually mutates conversation structure.
messages = [_sanitize_empty_text_content(m) for m in messages]
answered_by_assistant_index: Final = cache(partial(answered_ids_by_index, messages))
messages = [
_sanitize_empty_text_content(m, partial(_answered_at, answered_by_assistant_index, i))
for i, m in enumerate(messages)
]
replaced_server_tool_answers: Final = server_tool_answers_replaced_by_their_result(messages)
client_answered_tool_ids: Final = _tool_call_ids_in_tool_messages(messages) - replaced_server_tool_answers
@ -3225,7 +3240,6 @@ def anthropic_messages_pt(
inline_base64_media: Final = force_base64 or requires_inline_base64_media(model, llm_provider)
tool_result_later_in_turn: Final = _tool_result_later_in_turn(messages, replaced_server_tool_answers)
answered_by_assistant_index: Final = cache(partial(answered_ids_by_index, messages))
msg_i = 0
while msg_i < len(messages):
user_content: list[AnthropicMessagesUserMessageValues] = []

View file

@ -9438,6 +9438,40 @@ def test_anthropic_messages_pt_drops_a_native_tool_use_nobody_answered():
assert result[1]["content"] == [{"type": "text", "text": "checking"}]
def _empty_text_beside_native_tool_use(follow_up: dict[str, object]) -> list[dict[str, object]]:
return [
{"role": "user", "content": "what is the weather"},
{
"role": "assistant",
"content": [
{"type": "text", "text": " "},
{"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {}},
],
},
follow_up,
]
@pytest.mark.parametrize("modify_params", [False, True])
def test_anthropic_messages_pt_drops_empty_text_beside_an_answered_native_tool_use(monkeypatch, modify_params):
monkeypatch.setattr(litellm, "modify_params", modify_params)
messages: Final = _empty_text_beside_native_tool_use({"role": "tool", "tool_call_id": "toolu_01", "content": "sunny"})
result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic")
assert result[1]["content"] == [{"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {}}]
@pytest.mark.parametrize("modify_params", [False, True])
def test_anthropic_messages_pt_keeps_the_placeholder_beside_an_unanswered_native_tool_use(monkeypatch, modify_params):
monkeypatch.setattr(litellm, "modify_params", modify_params)
messages: Final = _empty_text_beside_native_tool_use({"role": "user", "content": "never mind"})
result = anthropic_messages_pt(messages=messages, model="claude-sonnet-5", llm_provider="anthropic")
assert result[1]["content"] == [{"type": "text", "text": _EMPTY_TEXT_PLACEHOLDER}]
def _answered_native_tool_use(tool_use: dict[str, object]) -> list[dict[str, object]]:
return [
{"role": "user", "content": "what is the weather"},

View file

@ -449,7 +449,7 @@ class TestMessageSanitization:
],
}
sanitized = _sanitize_empty_text_content(message)
sanitized = _sanitize_empty_text_content(message, frozenset)
assert [b["type"] for b in sanitized["content"]] == ["thinking"]
assert PLACEHOLDER not in json.dumps(sanitized)