]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
server : fix image blocks in tool_result being dropped during Anthropic OpenAI conver...
authorquei <redacted>
Sun, 12 Jul 2026 15:43:51 +0000 (23:43 +0800)
committerGitHub <redacted>
Sun, 12 Jul 2026 15:43:51 +0000 (17:43 +0200)
* server : fix image blocks in tool_result being dropped during Anthropic→OpenAI conversion

server_chat_convert_anthropic_to_oai() silently discarded image blocks

inside Anthropic tool_result content. This broke multimodal tool outputs

(e.g. a tool that returns an image) because the model never received the

image.

When tool_result contains image blocks, convert them to OpenAI

multimodal content parts (text + image_url array). Plain-text results

remain simple strings for backwards compatibility.

* server : add test for image blocks in Anthropic tool_result conversion

tools/server/server-chat.cpp
tools/server/tests/unit/test_compat_anthropic.py

index 02858a2a028cad55e444539f083d02db09fa2df5..31f94e0233071c1ab45f36f49002b2c8aa4c2388 100644 (file)
@@ -431,22 +431,70 @@ json server_chat_convert_anthropic_to_oai(const json & body) {
                     std::string tool_use_id = json_value(block, "tool_use_id", std::string());
 
                     auto result_content = json_value(block, "content", json());
-                    std::string result_text;
                     if (result_content.is_string()) {
-                        result_text = result_content.get<std::string>();
+                        tool_results.push_back({
+                            {"role", "tool"},
+                            {"tool_call_id", tool_use_id},
+                            {"content", result_content.get<std::string>()}
+                        });
                     } else if (result_content.is_array()) {
+                        // Single-pass: build both text and content_parts, decide format at the end
+                        std::string result_text;
+                        json content_parts = json::array();
+                        bool has_images = false;
+
                         for (const auto & c : result_content) {
-                            if (json_value(c, "type", std::string()) == "text") {
-                                result_text += json_value(c, "text", std::string());
+                            std::string c_type = json_value(c, "type", std::string());
+                            if (c_type == "text") {
+                                std::string text = json_value(c, "text", std::string());
+                                result_text += text;
+                                content_parts.push_back({
+                                    {"type", "text"},
+                                    {"text", text}
+                                });
+                            } else if (c_type == "image") {
+                                has_images = true;
+                                json source = json_value(c, "source", json::object());
+                                std::string source_type = json_value(source, "type", std::string());
+                                if (source_type == "base64") {
+                                    std::string media_type = json_value(source, "media_type", std::string("image/jpeg"));
+                                    std::string data = json_value(source, "data", std::string());
+                                    std::string url = "data:" + media_type + ";base64," + data;
+                                    content_parts.push_back({
+                                        {"type", "image_url"},
+                                        {"image_url", {{"url", url}}}
+                                    });
+                                } else if (source_type == "url") {
+                                    content_parts.push_back({
+                                        {"type", "image_url"},
+                                        {"image_url", {{"url", json_value(source, "url", std::string())}}}
+                                    });
+                                }
                             }
                         }
-                    }
 
-                    tool_results.push_back({
-                        {"role", "tool"},
-                        {"tool_call_id", tool_use_id},
-                        {"content", result_text}
-                    });
+                        if (!has_images) {
+                            // Text-only: collapse to a plain string for maximum compatibility
+                            tool_results.push_back({
+                                {"role", "tool"},
+                                {"tool_call_id", tool_use_id},
+                                {"content", result_text}
+                            });
+                        } else {
+                            // Mixed or image-only: use array content parts (OpenAI multimodal tool format)
+                            tool_results.push_back({
+                                {"role", "tool"},
+                                {"tool_call_id", tool_use_id},
+                                {"content", content_parts}
+                            });
+                        }
+                    } else {
+                        tool_results.push_back({
+                            {"role", "tool"},
+                            {"tool_call_id", tool_use_id},
+                            {"content", ""}
+                        });
+                    }
                 }
             }
 
index ef1948d4a532ad56317d94b36321cb99fb18fd18..e23947cdde546e72be8e7ae37b3556d0254209fd 100644 (file)
@@ -402,6 +402,65 @@ def test_anthropic_tool_result_with_text():
     assert len(res.body["content"]) > 0
 
 
+def test_anthropic_tool_result_with_image():
+    """Test tool result containing mixed text and image blocks
+
+    Verifies that image blocks inside Anthropic tool_result content are
+    properly converted to OpenAI image_url format rather than being
+    silently dropped. With a non-multimodal model, the converted image
+    triggers a clear error message instead of being ignored.
+    """
+    server.jinja = True
+    server.start()
+
+    # Small 1x1 red PNG image in base64 (same as vision tests)
+    red_pixel_png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
+
+    res = server.make_request("POST", "/v1/messages", data={
+        "model": "test",
+        "max_tokens": 100,
+        "messages": [
+            {"role": "user", "content": "What is in this image?"},
+            {
+                "role": "assistant",
+                "content": [
+                    {
+                        "type": "tool_use",
+                        "id": "tool_1",
+                        "name": "read",
+                        "input": {"file": "test.png"}
+                    }
+                ]
+            },
+            {
+                "role": "user",
+                "content": [
+                    {
+                        "type": "tool_result",
+                        "tool_use_id": "tool_1",
+                        "content": [
+                            {"type": "text", "text": "File: test.png"},
+                            {
+                                "type": "image",
+                                "source": {
+                                    "type": "base64",
+                                    "media_type": "image/png",
+                                    "data": red_pixel_png
+                                }
+                            }
+                        ]
+                    }
+                ]
+            }
+        ]
+    })
+
+    # Without the fix, image block would cause "unsupported content[].type"
+    # With the fix, image is converted to image_url but tinyllama doesn't support images
+    assert res.status_code == 500
+    assert "image input is not supported" in res.body.get("error", {}).get("message", "").lower()
+
+
 def test_anthropic_tool_result_error():
     """Test tool result with error flag"""
     server.jinja = True