2026-04-22 12:42:08 +00:00
|
|
|
"""Tests for AnthropicProvider._tool_result_block image_url conversion.
|
|
|
|
|
|
|
|
|
|
Regression for: tool results containing OpenAI-format image_url blocks
|
|
|
|
|
(e.g. from read_file on an image file, via build_image_content_blocks)
|
|
|
|
|
were passed to Anthropic unconverted, causing silent image drops with a
|
|
|
|
|
"Non-transient LLM error with image content, retrying without images"
|
|
|
|
|
warning.
|
2026-05-30 01:10:36 +08:00
|
|
|
|
|
|
|
|
Also tests that bare dicts without a "type" field are coerced to text
|
|
|
|
|
blocks, fixing Anthropic "content.0.type: Field required" rejections (#3993).
|
2026-04-22 12:42:08 +00:00
|
|
|
"""
|
|
|
|
|
|
2026-06-23 23:44:57 +08:00
|
|
|
from types import SimpleNamespace
|
|
|
|
|
|
2026-04-22 12:42:08 +00:00
|
|
|
from nanobot.providers.anthropic_provider import AnthropicProvider
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_tool_result_block_converts_image_url_in_list_content():
|
|
|
|
|
"""image_url blocks inside tool_result list content must be translated
|
|
|
|
|
to Anthropic-native image blocks; sibling text blocks pass through."""
|
|
|
|
|
msg = {
|
|
|
|
|
"role": "tool",
|
|
|
|
|
"tool_call_id": "call_1",
|
|
|
|
|
"content": [
|
|
|
|
|
{
|
|
|
|
|
"type": "image_url",
|
|
|
|
|
"image_url": {"url": "data:image/png;base64,AAAA"},
|
|
|
|
|
"_meta": {"path": "/tmp/x.png"},
|
|
|
|
|
},
|
|
|
|
|
{"type": "text", "text": "(Image file: /tmp/x.png)"},
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
block = AnthropicProvider._tool_result_block(msg)
|
|
|
|
|
|
|
|
|
|
assert block["type"] == "tool_result"
|
|
|
|
|
assert block["tool_use_id"] == "call_1"
|
|
|
|
|
content = block["content"]
|
|
|
|
|
assert isinstance(content, list)
|
|
|
|
|
assert content[0] == {
|
|
|
|
|
"type": "image",
|
|
|
|
|
"source": {
|
|
|
|
|
"type": "base64",
|
|
|
|
|
"media_type": "image/png",
|
|
|
|
|
"data": "AAAA",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
assert content[1] == {"type": "text", "text": "(Image file: /tmp/x.png)"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_tool_result_block_preserves_string_content():
|
|
|
|
|
"""String content must be passed through unchanged; the image-conversion
|
|
|
|
|
path for lists must not affect the string path."""
|
|
|
|
|
msg = {
|
|
|
|
|
"role": "tool",
|
|
|
|
|
"tool_call_id": "call_2",
|
|
|
|
|
"content": "plain tool output",
|
|
|
|
|
}
|
|
|
|
|
block = AnthropicProvider._tool_result_block(msg)
|
|
|
|
|
|
|
|
|
|
assert block["type"] == "tool_result"
|
|
|
|
|
assert block["tool_use_id"] == "call_2"
|
|
|
|
|
assert block["content"] == "plain tool output"
|
2026-05-30 01:10:36 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_convert_user_content_coerces_typeless_dict():
|
|
|
|
|
"""Bare dicts without a "type" field must be coerced to text blocks.
|
|
|
|
|
Regression for #3993: tools returning plain dicts caused Anthropic to
|
|
|
|
|
reject the request with "content.0.type: Field required"."""
|
|
|
|
|
result = AnthropicProvider._convert_user_content([
|
|
|
|
|
{"foo": "bar"},
|
|
|
|
|
{"type": "text", "text": "ok"},
|
|
|
|
|
])
|
2026-06-26 11:10:03 +08:00
|
|
|
assert result[0] == {"type": "text", "text": '{"foo": "bar"}'}
|
2026-05-30 01:10:36 +08:00
|
|
|
assert result[1] == {"type": "text", "text": "ok"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_convert_user_content_coerces_mixed_typeless():
|
|
|
|
|
"""Multiple typeless items and non-dict items are all handled."""
|
|
|
|
|
result = AnthropicProvider._convert_user_content([
|
|
|
|
|
42,
|
|
|
|
|
{"key": "val"},
|
|
|
|
|
])
|
|
|
|
|
assert result[0] == {"type": "text", "text": "42"}
|
2026-06-26 11:10:03 +08:00
|
|
|
assert result[1] == {"type": "text", "text": '{"key": "val"}'}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_assistant_blocks_coerce_typeless_dict_to_json_text():
|
|
|
|
|
blocks = AnthropicProvider._assistant_blocks({
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"content": [{"answer": "ok", "count": 2}],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
assert blocks == [{"type": "text", "text": '{"answer": "ok", "count": 2}'}]
|
2026-06-09 14:50:40 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_convert_assistant_message_repairs_history_tool_arguments():
|
|
|
|
|
blocks = AnthropicProvider._assistant_blocks({
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"content": None,
|
|
|
|
|
"tool_calls": [{
|
|
|
|
|
"id": "toolu_1",
|
|
|
|
|
"function": {"name": "read_file", "arguments": '{path:"foo.txt"}'},
|
|
|
|
|
}],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
assert blocks[0]["type"] == "tool_use"
|
|
|
|
|
assert blocks[0]["input"] == {"path": "foo.txt"}
|
2026-06-17 09:24:06 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_anthropic_sanitizes_invalid_tool_ids_consistently():
|
|
|
|
|
"""Invalid restored IDs must be valid for Anthropic and keep pairs matched."""
|
|
|
|
|
blocks = AnthropicProvider._assistant_blocks({
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"content": None,
|
|
|
|
|
"tool_calls": [{
|
|
|
|
|
"id": "call_abc|rs.same",
|
|
|
|
|
"function": {"name": "read_file", "arguments": "{}"},
|
|
|
|
|
}],
|
|
|
|
|
})
|
|
|
|
|
result = AnthropicProvider._tool_result_block({
|
|
|
|
|
"role": "tool",
|
|
|
|
|
"tool_call_id": "call_abc|rs.same",
|
|
|
|
|
"content": "ok",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
tool_id = blocks[0]["id"]
|
|
|
|
|
assert tool_id == result["tool_use_id"]
|
|
|
|
|
assert tool_id != "call_abc|rs.same"
|
|
|
|
|
assert all(ch.isalnum() or ch in "_-" for ch in tool_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_anthropic_sanitized_tool_ids_avoid_simple_collisions():
|
|
|
|
|
"""Replacement-only sanitizing would collapse these two ids to call_a."""
|
|
|
|
|
blocks = AnthropicProvider._assistant_blocks({
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"content": None,
|
|
|
|
|
"tool_calls": [
|
|
|
|
|
{"id": "call.a", "function": {"name": "a", "arguments": "{}"}},
|
|
|
|
|
{"id": "call|a", "function": {"name": "b", "arguments": "{}"}},
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
ids = [block["id"] for block in blocks if block["type"] == "tool_use"]
|
|
|
|
|
assert len(ids) == len(set(ids)) == 2
|
|
|
|
|
assert all(all(ch.isalnum() or ch in "_-" for ch in tool_id) for tool_id in ids)
|
2026-06-23 23:44:57 +08:00
|
|
|
|
|
|
|
|
|
2026-06-23 23:52:47 +08:00
|
|
|
def test_anthropic_convert_messages_remaps_duplicate_history_tool_ids():
|
|
|
|
|
provider = AnthropicProvider.__new__(AnthropicProvider)
|
|
|
|
|
|
|
|
|
|
_system, messages = provider._convert_messages([
|
|
|
|
|
{"role": "user", "content": "check both files"},
|
|
|
|
|
{
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"content": "",
|
|
|
|
|
"tool_calls": [
|
|
|
|
|
{
|
|
|
|
|
"id": "toolu_same",
|
|
|
|
|
"type": "function",
|
|
|
|
|
"function": {"name": "read_file", "arguments": '{"path":"a.txt"}'},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"id": "toolu_same",
|
|
|
|
|
"type": "function",
|
|
|
|
|
"function": {"name": "read_file", "arguments": '{"path":"b.txt"}'},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{"role": "tool", "tool_call_id": "toolu_same", "name": "read_file", "content": "a"},
|
|
|
|
|
{"role": "tool", "tool_call_id": "toolu_same", "name": "read_file", "content": "b"},
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
tool_uses = [
|
|
|
|
|
block
|
|
|
|
|
for block in messages[1]["content"]
|
|
|
|
|
if isinstance(block, dict) and block.get("type") == "tool_use"
|
|
|
|
|
]
|
|
|
|
|
tool_results = [
|
|
|
|
|
block
|
|
|
|
|
for block in messages[2]["content"]
|
|
|
|
|
if isinstance(block, dict) and block.get("type") == "tool_result"
|
|
|
|
|
]
|
|
|
|
|
tool_use_ids = [block["id"] for block in tool_uses]
|
|
|
|
|
tool_result_ids = [block["tool_use_id"] for block in tool_results]
|
|
|
|
|
|
|
|
|
|
assert len(tool_use_ids) == 2
|
|
|
|
|
assert tool_use_ids[0] == "toolu_same"
|
|
|
|
|
assert tool_use_ids[1] == "toolu_same__dedupe_2"
|
|
|
|
|
assert tool_result_ids == tool_use_ids
|
|
|
|
|
assert tool_uses[0]["input"] == {"path": "a.txt"}
|
|
|
|
|
assert tool_uses[1]["input"] == {"path": "b.txt"}
|
|
|
|
|
|
|
|
|
|
|
2026-06-23 23:44:57 +08:00
|
|
|
def test_anthropic_parse_response_remaps_duplicate_tool_use_ids():
|
|
|
|
|
response = SimpleNamespace(
|
|
|
|
|
content=[
|
|
|
|
|
SimpleNamespace(
|
|
|
|
|
type="tool_use",
|
|
|
|
|
id="toolu_same",
|
|
|
|
|
name="read_file",
|
|
|
|
|
input={"path": "a.txt"},
|
|
|
|
|
),
|
|
|
|
|
SimpleNamespace(
|
|
|
|
|
type="tool_use",
|
|
|
|
|
id="toolu_same",
|
|
|
|
|
name="read_file",
|
|
|
|
|
input={"path": "b.txt"},
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
stop_reason="tool_use",
|
|
|
|
|
usage=None,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = AnthropicProvider._parse_response(response)
|
|
|
|
|
|
|
|
|
|
assert len(result.tool_calls) == 2
|
|
|
|
|
assert result.tool_calls[0].id == "toolu_same"
|
|
|
|
|
assert result.tool_calls[0].arguments == {"path": "a.txt"}
|
|
|
|
|
assert result.tool_calls[1].id != "toolu_same"
|
|
|
|
|
assert result.tool_calls[1].id.startswith("toolu_")
|
|
|
|
|
assert result.tool_calls[1].arguments == {"path": "b.txt"}
|