This is an automated email from the ASF dual-hosted git repository.

wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git


The following commit(s) were added to refs/heads/main by this push:
     new ddd2c020 [integration][java][python] Send user images and documents to 
Anthropic (#1204)
ddd2c020 is described below

commit ddd2c020e59889f3acbd766361ed586a8e5a470a
Author: Edson <[email protected]>
AuthorDate: Fri Oct 16 02:44:32 2026 -0400

    [integration][java][python] Send user images and documents to Anthropic 
(#1204)
    
    Generated-by: Claude Code 2.1.259 (Claude Opus 5.5)
---
 .../calling-llms/chat-models/providers.md          |  13 +-
 docs/content/docs/development/calling-llms/chat.md |  10 +-
 .../anthropic/AnthropicChatModelConnection.java    |   5 +-
 .../chatmodels/anthropic/AnthropicChatUtils.java   | 124 ++++++++++++-
 .../AnthropicChatModelConnectionTest.java          |  20 --
 .../anthropic/AnthropicMultimodalTest.java         | 205 +++++++++++++++++++++
 .../chat_models/anthropic/anthropic_chat_model.py  |  89 ++++++++-
 .../anthropic/tests/test_anthropic_multimodal.py   | 193 +++++++++++++++++++
 .../tests/test_text_only_providers_reject_media.py |   4 -
 9 files changed, 628 insertions(+), 35 deletions(-)

diff --git 
a/docs/content/docs/development/calling-llms/chat-models/providers.md 
b/docs/content/docs/development/calling-llms/chat-models/providers.md
index 488b42a0..f405a468 100644
--- a/docs/content/docs/development/calling-llms/chat-models/providers.md
+++ b/docs/content/docs/development/calling-llms/chat-models/providers.md
@@ -258,6 +258,17 @@ public class MyAgent extends Agent {
 
 {{< /tabs >}}
 
+**Multimodal input.** The media blocks of a user message are sent as Anthropic 
content blocks, in block order, next to its text blocks:
+
+| Block | Sent as |
+|---|---|
+| `TextBlock` | a `text` block |
+| `ImageBlock` | an `image` block, by URL or Base64 data, for `image/jpeg`, 
`image/png`, `image/gif` or `image/webp` |
+| `DocumentBlock` | a `document` block titled by the block's `name`: 
`application/pdf` by URL or Base64 data, or `text/plain` Base64 data sent as 
decoded UTF-8 text |
+| `AudioBlock`, `VideoBlock` | not supported |
+
+Other image or document types, plain text given by URL, audio, video, and 
media in assistant messages or tool results raise 
`UnsupportedContentBlockException` (Java) or `UnsupportedContentBlockError` 
(Python) before the request is sent.
+
 #### Available Models
 
 Visit the [Anthropic Models 
documentation](https://docs.anthropic.com/en/docs/about-claude/models) for the 
complete and up-to-date list of available chat models.
@@ -857,7 +868,7 @@ The Chat Completions connections (OpenAI, Azure OpenAI and 
vLLM) send the media
 
 A user message with only text blocks is still sent as a plain string. Media in 
system, assistant or tool messages, audio or documents given by URL, other 
audio types, and video raise `UnsupportedContentBlockException` (Java) or 
`UnsupportedContentBlockError` (Python) before the request is sent. Whether a 
model accepts a given part depends on the model and the server.
 
-The other built-in providers (Anthropic, Gemini, Amazon Bedrock, IBM 
watsonx.ai, DashScope and the OpenAI Responses API) do not send media yet: a 
message with a media block raises the same error instead of being sent as its 
text.
+The other built-in providers (Gemini, Amazon Bedrock, IBM watsonx.ai, 
DashScope and the OpenAI Responses API) do not send media yet: a message with a 
media block raises the same error instead of being sent as its text.
 
 #### Responses API
 
diff --git a/docs/content/docs/development/calling-llms/chat.md 
b/docs/content/docs/development/calling-llms/chat.md
index b9216033..1cd13e17 100644
--- a/docs/content/docs/development/calling-llms/chat.md
+++ b/docs/content/docs/development/calling-llms/chat.md
@@ -219,10 +219,12 @@ For tools that return text or media, see
 **Provider support:** The OpenAI Chat Completions integration, and the Azure 
OpenAI
 and vLLM integrations built on it, send media blocks to the model; see
 [Multimodal Input]({{< ref 
"docs/development/calling-llms/chat-models/providers#multimodal-input" >}}) for 
what each block becomes. Ollama sends
-Base64 images; see the [Ollama provider section]({{< ref 
"docs/development/calling-llms/chat-models/providers#ollama" >}}). The 
Anthropic, Gemini, Amazon Bedrock, IBM
-watsonx.ai, DashScope and OpenAI Responses integrations do not send media yet: 
a
-message with a media block raises `UnsupportedContentBlockException` (Java) or
-`UnsupportedContentBlockError` (Python).
+Base64 images, and Anthropic sends images and PDF or plain-text documents; see 
the
+[Ollama]({{< ref "docs/development/calling-llms/chat-models/providers#ollama" 
>}}) and
+[Anthropic]({{< ref 
"docs/development/calling-llms/chat-models/providers#anthropic" >}}) provider
+sections. The Gemini, Amazon Bedrock, IBM watsonx.ai, DashScope and OpenAI 
Responses
+integrations do not send media yet: a message with a media block raises
+`UnsupportedContentBlockException` (Java) or `UnsupportedContentBlockError` 
(Python).
 
 For example, a message can contain a question followed by an image:
 
diff --git 
a/integrations/chat-models/anthropic/src/main/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnection.java
 
b/integrations/chat-models/anthropic/src/main/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnection.java
index 7331e82a..a9498d65 100644
--- 
a/integrations/chat-models/anthropic/src/main/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnection.java
+++ 
b/integrations/chat-models/anthropic/src/main/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnection.java
@@ -458,14 +458,15 @@ public class AnthropicChatModelConnection extends 
BaseChatModelConnection {
             List<org.apache.flink.agents.api.tools.Tool> tools,
             Map<String, Object> modelParams,
             Object outputSchema) {
-        // Media blocks are not sent yet; fail rather than drop them (#1059).
-        UnsupportedContentBlockException.rejectMedia("Anthropic", messages);
         try {
             BuiltRequest built = buildRequest(messages, tools, modelParams, 
outputSchema);
             Message response = client.messages().create(built.params);
             ChatResult result = convertResponse(built, response);
 
             return result;
+        } catch (UnsupportedContentBlockException e) {
+            // Unchanged, so callers can catch it by type; other build 
failures stay wrapped.
+            throw e;
         } catch (Exception e) {
             throw new RuntimeException("Failed to call Anthropic messages 
API.", e);
         }
diff --git 
a/integrations/chat-models/anthropic/src/main/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatUtils.java
 
b/integrations/chat-models/anthropic/src/main/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatUtils.java
index f1d6d8f9..db1f431c 100644
--- 
a/integrations/chat-models/anthropic/src/main/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatUtils.java
+++ 
b/integrations/chat-models/anthropic/src/main/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatUtils.java
@@ -18,10 +18,15 @@
 package org.apache.flink.agents.integrations.chatmodels.anthropic;
 
 import com.anthropic.core.JsonValue;
+import com.anthropic.models.messages.Base64ImageSource;
+import com.anthropic.models.messages.Base64PdfSource;
 import com.anthropic.models.messages.ContentBlock;
 import com.anthropic.models.messages.ContentBlockParam;
+import com.anthropic.models.messages.DocumentBlockParam;
+import com.anthropic.models.messages.ImageBlockParam;
 import com.anthropic.models.messages.Message;
 import com.anthropic.models.messages.MessageParam;
+import com.anthropic.models.messages.PlainTextSource;
 import com.anthropic.models.messages.RedactedThinkingBlockParam;
 import com.anthropic.models.messages.StopReason;
 import com.anthropic.models.messages.TextBlockParam;
@@ -29,23 +34,35 @@ import com.anthropic.models.messages.ThinkingBlockParam;
 import com.anthropic.models.messages.Tool;
 import com.anthropic.models.messages.ToolResultBlockParam;
 import com.anthropic.models.messages.ToolUseBlockParam;
+import com.anthropic.models.messages.UrlImageSource;
+import com.anthropic.models.messages.UrlPdfSource;
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.flink.agents.api.chat.messages.Base64Source;
 import org.apache.flink.agents.api.chat.messages.ChatMessage;
 import org.apache.flink.agents.api.chat.messages.ChatResult;
+import org.apache.flink.agents.api.chat.messages.DocumentBlock;
+import org.apache.flink.agents.api.chat.messages.ImageBlock;
+import org.apache.flink.agents.api.chat.messages.MediaBlock;
 import org.apache.flink.agents.api.chat.messages.MessageRole;
 import org.apache.flink.agents.api.chat.messages.ReasoningBlock;
 import org.apache.flink.agents.api.chat.messages.TextBlock;
 import org.apache.flink.agents.api.chat.messages.TokenUsage;
 import org.apache.flink.agents.api.chat.messages.ToolCallBlock;
 import org.apache.flink.agents.api.chat.messages.ToolResultBlock;
+import 
org.apache.flink.agents.api.chat.messages.UnsupportedContentBlockException;
+import org.apache.flink.agents.api.chat.messages.UrlSource;
 import org.apache.flink.agents.api.tools.ToolMetadata;
 
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
+import java.util.Base64;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
+import java.util.Set;
 import java.util.stream.Collectors;
 
 /** Converts Flink Agents messages and tool definitions to and from Anthropic 
SDK types. */
@@ -60,6 +77,10 @@ final class AnthropicChatUtils {
     private static final String REDACTED_THINKING = "redacted_thinking";
     private static final String REDACTED_DATA = "data";
 
+    /** The image media types the Anthropic Messages API accepts. */
+    private static final Set<String> IMAGE_MEDIA_TYPES =
+            Set.of("image/jpeg", "image/png", "image/gif", "image/webp");
+
     private AnthropicChatUtils() {}
 
     static List<TextBlockParam> extractSystemMessages(List<ChatMessage> 
messages) {
@@ -69,9 +90,20 @@ final class AnthropicChatUtils {
                 .collect(Collectors.toList());
     }
 
+    /**
+     * Converts a message to an Anthropic message. Only user messages can 
carry media: images (JPEG,
+     * PNG, GIF or WebP) and PDF documents by Base64 data or URL, and 
plain-text documents by Base64
+     * data. Any other media, or media in an assistant message or a tool 
result, throws {@link
+     * UnsupportedContentBlockException}.
+     */
     static MessageParam convertToAnthropicMessage(ChatMessage message) {
         if (message.getRole() == MessageRole.TOOL) {
-            return convertToolResult((ToolResultBlock) 
message.getBlocks().get(0));
+            ToolResultBlock result = (ToolResultBlock) 
message.getBlocks().get(0);
+            requireTextOnly(message, result.getBlocks());
+            return convertToolResult(result);
+        }
+        if (message.getRole() != MessageRole.USER) {
+            requireTextOnly(message, message.getBlocks());
         }
         List<ContentBlockParam> blocks = new ArrayList<>();
         for (org.apache.flink.agents.api.chat.messages.ContentBlock block : 
message.getBlocks()) {
@@ -90,6 +122,8 @@ final class AnthropicChatUtils {
                                         .name(call.getName())
                                         .input(toJsonValue(call.getInput()))
                                         .build()));
+            } else if (block instanceof MediaBlock) {
+                blocks.add(toMediaBlockParam((MediaBlock) block));
             } else if (block instanceof ReasoningBlock) {
                 ContentBlockParam reasoning = 
convertReasoning((ReasoningBlock) block);
                 if (reasoning != null) {
@@ -109,6 +143,94 @@ final class AnthropicChatUtils {
                 .build();
     }
 
+    private static ContentBlockParam toMediaBlockParam(MediaBlock block) {
+        if (block instanceof ImageBlock) {
+            String mediaType = essence(block.getMediaType());
+            if (!IMAGE_MEDIA_TYPES.contains(mediaType)) {
+                throw unsupported(block, "images must be JPEG, PNG, GIF or 
WebP");
+            }
+            ImageBlockParam.Builder param = ImageBlockParam.builder();
+            if (block.getSource() instanceof UrlSource) {
+                param.source(
+                        UrlImageSource.builder()
+                                .url(((UrlSource) block.getSource()).getUrl())
+                                .build());
+            } else {
+                param.source(
+                        Base64ImageSource.builder()
+                                .data(((Base64Source) 
block.getSource()).getData())
+                                
.mediaType(Base64ImageSource.MediaType.of(mediaType))
+                                .build());
+            }
+            return ContentBlockParam.ofImage(param.build());
+        }
+        if (block instanceof DocumentBlock) {
+            return 
ContentBlockParam.ofDocument(toDocumentBlockParam((DocumentBlock) block));
+        }
+        throw unsupported(block, "Anthropic accepts images and PDF or 
plain-text documents only");
+    }
+
+    private static DocumentBlockParam toDocumentBlockParam(DocumentBlock 
document) {
+        String mediaType = essence(document.getMediaType());
+        DocumentBlockParam.Builder param = DocumentBlockParam.builder();
+        if (document.getName() != null) {
+            param.title(document.getName());
+        }
+        if ("application/pdf".equals(mediaType)) {
+            if (document.getSource() instanceof UrlSource) {
+                param.source(
+                        UrlPdfSource.builder()
+                                .url(((UrlSource) 
document.getSource()).getUrl())
+                                .build());
+            } else {
+                param.source(
+                        Base64PdfSource.builder()
+                                .data(((Base64Source) 
document.getSource()).getData())
+                                .build());
+            }
+            return param.build();
+        }
+        if ("text/plain".equals(mediaType) && document.getSource() instanceof 
Base64Source) {
+            byte[] text;
+            try {
+                text = Base64.getDecoder().decode(((Base64Source) 
document.getSource()).getData());
+            } catch (IllegalArgumentException e) {
+                throw new IllegalArgumentException(
+                        "A document block's base64 data could not be 
decoded.", e);
+            }
+            return param.source(
+                            PlainTextSource.builder()
+                                    .data(new String(text, 
StandardCharsets.UTF_8))
+                                    .build())
+                    .build();
+        }
+        throw unsupported(document, "documents must be PDF, or Base64 plain 
text, for Anthropic");
+    }
+
+    private static void requireTextOnly(
+            ChatMessage message,
+            List<? extends 
org.apache.flink.agents.api.chat.messages.ContentBlock> blocks) {
+        for (org.apache.flink.agents.api.chat.messages.ContentBlock block : 
blocks) {
+            if (block instanceof MediaBlock) {
+                throw unsupported(
+                        block,
+                        "only user messages can carry media, not "
+                                + message.getRole().getValue()
+                                + " messages");
+            }
+        }
+    }
+
+    /** The media type without parameters, lower-cased. */
+    private static String essence(String mediaType) {
+        return mediaType.split(";", 2)[0].trim().toLowerCase(Locale.ROOT);
+    }
+
+    private static UnsupportedContentBlockException unsupported(
+            org.apache.flink.agents.api.chat.messages.ContentBlock block, 
String reason) {
+        return UnsupportedContentBlockException.forBlock("Anthropic", block, 
reason);
+    }
+
     private static MessageParam convertToolResult(ToolResultBlock result) {
         StringBuilder text = new StringBuilder();
         for (org.apache.flink.agents.api.chat.messages.ContentBlock block : 
result.getBlocks()) {
diff --git 
a/integrations/chat-models/anthropic/src/test/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnectionTest.java
 
b/integrations/chat-models/anthropic/src/test/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnectionTest.java
index 54d9077d..6ca83790 100644
--- 
a/integrations/chat-models/anthropic/src/test/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnectionTest.java
+++ 
b/integrations/chat-models/anthropic/src/test/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnectionTest.java
@@ -37,10 +37,8 @@ import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.flink.agents.api.chat.messages.ChatMessage;
 import org.apache.flink.agents.api.chat.messages.ChatResult;
-import org.apache.flink.agents.api.chat.messages.ImageBlock;
 import org.apache.flink.agents.api.chat.messages.MessageRole;
 import org.apache.flink.agents.api.chat.messages.ReasoningBlock;
-import 
org.apache.flink.agents.api.chat.messages.UnsupportedContentBlockException;
 import org.apache.flink.agents.api.chat.model.NativeStructuredOutputSupport;
 import org.apache.flink.agents.api.resource.ResourceContext;
 import org.apache.flink.agents.api.resource.ResourceDescriptor;
@@ -1277,24 +1275,6 @@ class AnthropicChatModelConnectionTest {
         }
     }
 
-    @Test
-    @DisplayName("Media blocks fail explicitly instead of being dropped")
-    void testMediaBlocksFailExplicitly() {
-        List<ChatMessage> messages =
-                List.of(
-                        ChatMessage.user(
-                                List.of(
-                                        
org.apache.flink.agents.api.chat.messages.TextBlock.of(
-                                                "Describe this"),
-                                        ImageBlock.fromBase64("image/png", 
"aGVsbG8="))));
-
-        assertThatThrownBy(() -> connection().chat(messages, List.of(), new 
HashMap<>(), null))
-                .isInstanceOf(UnsupportedContentBlockException.class)
-                .hasMessage(
-                        "Anthropic cannot send an image block (image/png, 
base64 source): this"
-                                + " integration sends text only.");
-    }
-
     @Test
     void testNativeReasoningReplayPreservesSignedAndRedactedBlocks() {
         ChatMessage message =
diff --git 
a/integrations/chat-models/anthropic/src/test/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicMultimodalTest.java
 
b/integrations/chat-models/anthropic/src/test/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicMultimodalTest.java
new file mode 100644
index 00000000..409a820a
--- /dev/null
+++ 
b/integrations/chat-models/anthropic/src/test/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicMultimodalTest.java
@@ -0,0 +1,205 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.agents.integrations.chatmodels.anthropic;
+
+import com.anthropic.core.ObjectMappers;
+import com.fasterxml.jackson.databind.JsonNode;
+import org.apache.flink.agents.api.chat.messages.AudioBlock;
+import org.apache.flink.agents.api.chat.messages.Base64Source;
+import org.apache.flink.agents.api.chat.messages.ChatMessage;
+import org.apache.flink.agents.api.chat.messages.ContentBlock;
+import org.apache.flink.agents.api.chat.messages.DocumentBlock;
+import org.apache.flink.agents.api.chat.messages.ImageBlock;
+import org.apache.flink.agents.api.chat.messages.TextBlock;
+import org.apache.flink.agents.api.chat.messages.ToolResultBlock;
+import 
org.apache.flink.agents.api.chat.messages.UnsupportedContentBlockException;
+import org.apache.flink.agents.api.chat.messages.VideoBlock;
+import org.apache.flink.agents.api.resource.ResourceContext;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests how content blocks become Anthropic Messages API content blocks. */
+class AnthropicMultimodalTest {
+
+    private static final String URL = "https://example.com/cat.png?sig=secret";;
+    private static final String BASE64 = "aGVsbG8="; // "hello"
+
+    private static AnthropicChatModelConnection connection() {
+        ResourceDescriptor descriptor =
+                
ResourceDescriptor.Builder.newBuilder(AnthropicChatModelConnection.class.getName())
+                        .addInitialArgument("api_key", "test-key")
+                        .addInitialArgument("model", 
"claude-sonnet-4-20250514")
+                        .build();
+        return new AnthropicChatModelConnection(
+                descriptor, ResourceContext.fromGetResource((a, b) -> null));
+    }
+
+    private static Map<String, Object> params() {
+        Map<String, Object> params = new HashMap<>();
+        params.put("max_tokens", 256);
+        return params;
+    }
+
+    /** The first request message, as the SDK serializes it. */
+    private static JsonNode wire(ChatMessage message) {
+        return ObjectMappers.jsonMapper()
+                .valueToTree(
+                        connection()
+                                .buildRequest(List.of(message), List.of(), 
params(), null)
+                                .params
+                                .messages()
+                                .get(0));
+    }
+
+    @Test
+    @DisplayName("A text-only user message is sent as before, as one text 
block")
+    void testTextOnlyUserMessageIsUnchanged() {
+        JsonNode content = 
wire(ChatMessage.user(List.of(TextBlock.of("hi")))).get("content");
+
+        assertThat(content).hasSize(1);
+        assertThat(content.at("/0/type").asText()).isEqualTo("text");
+        assertThat(content.at("/0/text").asText()).isEqualTo("hi");
+    }
+
+    @Test
+    @DisplayName("Images become image blocks in block order, by URL or as 
Base64 data")
+    void testImagesBecomeImageBlocks() {
+        JsonNode content =
+                wire(ChatMessage.user(
+                                List.of(
+                                        TextBlock.of("Compare"),
+                                        ImageBlock.fromUrl("image/png", URL),
+                                        ImageBlock.fromBase64("image/png", 
BASE64))))
+                        .get("content");
+
+        assertThat(content).hasSize(3);
+        assertThat(content.at("/0/type").asText()).isEqualTo("text");
+        assertThat(content.at("/0/text").asText()).isEqualTo("Compare");
+        assertThat(content.at("/1/type").asText()).isEqualTo("image");
+        assertThat(content.at("/1/source/type").asText()).isEqualTo("url");
+        assertThat(content.at("/1/source/url").asText()).isEqualTo(URL);
+        assertThat(content.at("/2/source/type").asText()).isEqualTo("base64");
+        
assertThat(content.at("/2/source/media_type").asText()).isEqualTo("image/png");
+        assertThat(content.at("/2/source/data").asText()).isEqualTo(BASE64);
+    }
+
+    @Test
+    @DisplayName("PDFs and Base64 plain text become document blocks, titled by 
the block name")
+    void testDocumentsBecomeDocumentBlocks() {
+        JsonNode content =
+                wire(ChatMessage.user(
+                                List.of(
+                                        new DocumentBlock(
+                                                "application/pdf",
+                                                new Base64Source(BASE64),
+                                                "report.pdf",
+                                                null,
+                                                null),
+                                        DocumentBlock.fromUrl(
+                                                "application/pdf", 
"https://example.com/a.pdf";),
+                                        DocumentBlock.fromBase64("text/plain", 
BASE64))))
+                        .get("content");
+
+        assertThat(content.at("/0/type").asText()).isEqualTo("document");
+        assertThat(content.at("/0/source/type").asText()).isEqualTo("base64");
+        
assertThat(content.at("/0/source/media_type").asText()).isEqualTo("application/pdf");
+        assertThat(content.at("/0/source/data").asText()).isEqualTo(BASE64);
+        assertThat(content.at("/0/title").asText()).isEqualTo("report.pdf");
+        assertThat(content.at("/1/source/type").asText()).isEqualTo("url");
+        
assertThat(content.at("/1/source/url").asText()).isEqualTo("https://example.com/a.pdf";);
+        assertThat(content.at("/2/source/type").asText()).isEqualTo("text");
+        
assertThat(content.at("/2/source/media_type").asText()).isEqualTo("text/plain");
+        assertThat(content.at("/2/source/data").asText()).isEqualTo("hello");
+    }
+
+    @Test
+    @DisplayName("Blocks Anthropic has no block for fail without leaking the 
source")
+    void testUnsupportedBlocksFailExplicitly() {
+        List<ContentBlock> unsupported =
+                List.of(
+                        AudioBlock.fromBase64("audio/wav", BASE64),
+                        VideoBlock.fromUrl("video/mp4", URL),
+                        ImageBlock.fromBase64("image/bmp", BASE64),
+                        DocumentBlock.fromUrl("text/plain", URL),
+                        DocumentBlock.fromBase64("application/msword", 
BASE64));
+        for (ContentBlock block : unsupported) {
+            assertThatThrownBy(() -> 
wire(ChatMessage.user(List.of(TextBlock.of("hi"), block))))
+                    .isInstanceOf(UnsupportedContentBlockException.class)
+                    .hasMessageStartingWith("Anthropic cannot send a")
+                    .hasMessageContaining(" " + block.getType() + " block")
+                    .hasMessageNotContaining("secret")
+                    .hasMessageNotContaining(BASE64);
+        }
+    }
+
+    @Test
+    @DisplayName("Media in assistant messages and tool results fails 
explicitly")
+    void testMediaOutsideUserMessagesFails() {
+        List<ContentBlock> blocks =
+                List.of(TextBlock.of("see"), ImageBlock.fromUrl("image/png", 
URL));
+        ChatMessage assistant = ChatMessage.assistant(blocks);
+        ChatMessage tool =
+                ChatMessage.tool(
+                        new ToolResultBlock(
+                                "call-1",
+                                List.of(TextBlock.of("see"), 
ImageBlock.fromUrl("image/png", URL)),
+                                false));
+
+        for (ChatMessage message : List.of(assistant, tool)) {
+            assertThatThrownBy(
+                            () ->
+                                    connection()
+                                            .buildRequest(
+                                                    
List.of(ChatMessage.user("hi"), message),
+                                                    List.of(),
+                                                    params(),
+                                                    null))
+                    .isInstanceOf(UnsupportedContentBlockException.class)
+                    .hasMessageContaining("only user messages can carry 
media");
+        }
+    }
+
+    @Test
+    @DisplayName("Undecodable plain-text data fails as invalid, not as 
unsupported")
+    void testInvalidBase64TextDocumentFails() {
+        ChatMessage message =
+                
ChatMessage.user(List.of(DocumentBlock.fromBase64("text/plain", "not 
base64!")));
+
+        assertThatThrownBy(() -> wire(message))
+                .isExactlyInstanceOf(IllegalArgumentException.class)
+                .hasMessage("A document block's base64 data could not be 
decoded.");
+    }
+
+    @Test
+    @DisplayName("chat() surfaces media errors with their own types")
+    void testChatKeepsMediaErrorTypes() {
+        ChatMessage message = 
ChatMessage.user(List.of(AudioBlock.fromBase64("audio/wav", BASE64)));
+
+        assertThatThrownBy(() -> connection().chat(List.of(message), 
List.of(), params(), null))
+                .isExactlyInstanceOf(UnsupportedContentBlockException.class);
+    }
+}
diff --git 
a/python/flink_agents/integrations/chat_models/anthropic/anthropic_chat_model.py
 
b/python/flink_agents/integrations/chat_models/anthropic/anthropic_chat_model.py
index 539a23be..bef145db 100644
--- 
a/python/flink_agents/integrations/chat_models/anthropic/anthropic_chat_model.py
+++ 
b/python/flink_agents/integrations/chat_models/anthropic/anthropic_chat_model.py
@@ -15,6 +15,8 @@
 #  See the License for the specific language governing permissions and
 # limitations under the License.
 
#################################################################################
+import base64
+import binascii
 import logging
 import uuid
 from typing import Any, Dict, List, Mapping, Sequence
@@ -27,12 +29,18 @@ from typing_extensions import override
 
 from flink_agents.api.agents.types import OutputSchema, render_output_schema
 from flink_agents.api.chat_message import (
+    Base64Source,
     ChatMessage,
+    ContentBlock,
+    DocumentBlock,
+    ImageBlock,
+    MediaBlock,
     MessageRole,
     ReasoningBlock,
     TextBlock,
     ToolCallBlock,
     UnsupportedContentBlockError,
+    UrlSource,
 )
 from flink_agents.api.chat_models.chat_model import (
     BaseChatModelConnection,
@@ -47,6 +55,7 @@ logger = logging.getLogger(__name__)
 _ANTHROPIC_METADATA = "anthropic"
 _THINKING = "thinking"
 _REDACTED_THINKING = "redacted_thinking"
+_IMAGE_MEDIA_TYPES = frozenset({"image/jpeg", "image/png", "image/gif", 
"image/webp"})
 
 
 def to_anthropic_tool(
@@ -66,8 +75,76 @@ def to_anthropic_tool(
     }
 
 
+def _unsupported(block: ContentBlock, reason: str) -> 
UnsupportedContentBlockError:
+    return UnsupportedContentBlockError.for_block("Anthropic", block, reason)
+
+
+def _essence(media_type: str) -> str:
+    """The media type without parameters, lower-cased."""
+    return media_type.split(";", 1)[0].strip().lower()
+
+
+def _to_document_block(block: DocumentBlock) -> Dict[str, Any]:
+    media_type = _essence(block.media_type)
+    if media_type == "application/pdf":
+        if isinstance(block.source, UrlSource):
+            source = {"type": "url", "url": block.source.url}
+        else:
+            source = {
+                "type": "base64",
+                "media_type": "application/pdf",
+                "data": block.source.data,
+            }
+    elif media_type == "text/plain" and isinstance(block.source, Base64Source):
+        try:
+            data = base64.b64decode(block.source.data, validate=True)
+        except binascii.Error as e:
+            msg = "A document block's base64 data could not be decoded."
+            raise ValueError(msg) from e
+        source = {
+            "type": "text",
+            "media_type": "text/plain",
+            "data": data.decode("utf-8", errors="replace"),
+        }
+    else:
+        raise _unsupported(
+            block, "documents must be PDF, or Base64 plain text, for Anthropic"
+        )
+    document: Dict[str, Any] = {"type": "document", "source": source}
+    if block.name is not None:
+        document["title"] = block.name
+    return document
+
+
+def _to_media_block(block: MediaBlock) -> Dict[str, Any]:
+    if isinstance(block, ImageBlock):
+        media_type = _essence(block.media_type)
+        if media_type not in _IMAGE_MEDIA_TYPES:
+            raise _unsupported(block, "images must be JPEG, PNG, GIF or WebP")
+        if isinstance(block.source, UrlSource):
+            source = {"type": "url", "url": block.source.url}
+        else:
+            source = {
+                "type": "base64",
+                "media_type": media_type,
+                "data": block.source.data,
+            }
+        return {"type": "image", "source": source}
+    if isinstance(block, DocumentBlock):
+        return _to_document_block(block)
+    raise _unsupported(
+        block, "Anthropic accepts images and PDF or plain-text documents only"
+    )
+
+
 def convert_to_anthropic_message(message: ChatMessage) -> MessageParam:
-    """Convert ChatMessage to Anthropic MessageParam format."""
+    """Convert ChatMessage to Anthropic MessageParam format.
+
+    Only user messages can carry media: images (JPEG, PNG, GIF or WebP) and PDF
+    documents by Base64 data or URL, and plain-text documents by Base64 data.
+    Any other media, or media in an assistant message or a tool result, raises
+    UnsupportedContentBlockError.
+    """
     blocks = (
         message.blocks[0].blocks if message.role == MessageRole.TOOL else 
message.blocks
     )
@@ -75,6 +152,14 @@ def convert_to_anthropic_message(message: ChatMessage) -> 
MessageParam:
     for block in blocks:
         if isinstance(block, TextBlock):
             content.append({"type": "text", "text": block.text})
+        elif isinstance(block, MediaBlock):
+            if message.role != MessageRole.USER:
+                reason = (
+                    "only user messages can carry media, "
+                    f"not {message.role.value} messages"
+                )
+                raise _unsupported(block, reason)
+            content.append(_to_media_block(block))
         elif isinstance(block, ToolCallBlock):
             content.append(
                 {
@@ -503,8 +588,6 @@ class AnthropicChatModelConnection(BaseChatModelConnection):
         ChatResult
             Model response message
         """
-        # Media blocks are not sent yet; fail rather than drop them (#1059).
-        UnsupportedContentBlockError.reject_media("Anthropic", messages)
         anthropic_tools = None
         if tools is not None:
             anthropic_tools = [
diff --git 
a/python/flink_agents/integrations/chat_models/anthropic/tests/test_anthropic_multimodal.py
 
b/python/flink_agents/integrations/chat_models/anthropic/tests/test_anthropic_multimodal.py
new file mode 100644
index 00000000..9956de1f
--- /dev/null
+++ 
b/python/flink_agents/integrations/chat_models/anthropic/tests/test_anthropic_multimodal.py
@@ -0,0 +1,193 @@
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+# limitations under the License.
+#################################################################################
+"""How content blocks reach an Anthropic messages request.
+
+Mirrors the Java AnthropicMultimodalTest.
+"""
+
+from typing import Any, Dict
+from unittest.mock import MagicMock
+
+import pytest
+from anthropic.types import Message, Usage
+from anthropic.types import TextBlock as AnthropicTextBlock
+
+from flink_agents.api.chat_message import (
+    AudioBlock,
+    Base64Source,
+    ChatMessage,
+    ContentBlock,
+    DocumentBlock,
+    ImageBlock,
+    TextBlock,
+    ToolResultBlock,
+    UnsupportedContentBlockError,
+    VideoBlock,
+)
+from flink_agents.integrations.chat_models.anthropic.anthropic_chat_model 
import (
+    AnthropicChatModelConnection,
+)
+
+URL = "https://example.com/cat.png?sig=secret";
+BASE64 = "aGVsbG8="  # "hello"
+
+
+def _connection() -> tuple[AnthropicChatModelConnection, MagicMock]:
+    connection = AnthropicChatModelConnection(api_key="fake-key")
+    client = MagicMock()
+    client.messages.create.return_value = Message(
+        id="m",
+        model="claude",
+        role="assistant",
+        type="message",
+        stop_reason="end_turn",
+        content=[AnthropicTextBlock(type="text", text="ok")],
+        usage=Usage(input_tokens=1, output_tokens=1),
+    )
+    connection._client = client
+    return connection, client
+
+
+def _chat(*messages: ChatMessage) -> MagicMock:
+    connection, client = _connection()
+    connection.chat(list(messages), model="claude-sonnet-4-5")
+    return client
+
+
+def _sent(message: ChatMessage) -> Dict[str, Any]:
+    """Chat through a mocked client and return the first message as sent."""
+    return _chat(message).messages.create.call_args.kwargs["messages"][0]
+
+
+def test_text_only_user_message_is_unchanged() -> None:
+    """A user message without media is sent as before, as one text block."""
+    assert _sent(ChatMessage.user([TextBlock(text="hi")]))["content"] == [
+        {"type": "text", "text": "hi"}
+    ]
+
+
+def test_images_become_image_blocks() -> None:
+    """Images become image blocks in block order, by URL or as Base64 data."""
+    content = _sent(
+        ChatMessage.user(
+            [
+                TextBlock(text="Compare"),
+                ImageBlock.from_url("image/png", URL),
+                ImageBlock.from_base64("image/png", BASE64),
+            ]
+        )
+    )["content"]
+
+    assert content == [
+        {"type": "text", "text": "Compare"},
+        {"type": "image", "source": {"type": "url", "url": URL}},
+        {
+            "type": "image",
+            "source": {"type": "base64", "media_type": "image/png", "data": 
BASE64},
+        },
+    ]
+
+
+def test_documents_become_document_blocks() -> None:
+    """PDFs and Base64 plain text become document blocks, titled by the 
name."""
+    content = _sent(
+        ChatMessage.user(
+            [
+                DocumentBlock(
+                    media_type="application/pdf",
+                    source=Base64Source(data=BASE64),
+                    name="report.pdf",
+                ),
+                DocumentBlock.from_url("application/pdf", 
"https://example.com/a.pdf";),
+                DocumentBlock.from_base64("text/plain", BASE64),
+            ]
+        )
+    )["content"]
+
+    assert content == [
+        {
+            "type": "document",
+            "source": {
+                "type": "base64",
+                "media_type": "application/pdf",
+                "data": BASE64,
+            },
+            "title": "report.pdf",
+        },
+        {
+            "type": "document",
+            "source": {"type": "url", "url": "https://example.com/a.pdf"},
+        },
+        {
+            "type": "document",
+            "source": {"type": "text", "media_type": "text/plain", "data": 
"hello"},
+        },
+    ]
+
+
[email protected](
+    "block",
+    [
+        AudioBlock.from_base64("audio/wav", BASE64),
+        VideoBlock.from_url("video/mp4", URL),
+        ImageBlock.from_base64("image/bmp", BASE64),
+        DocumentBlock.from_url("text/plain", URL),
+        DocumentBlock.from_base64("application/msword", BASE64),
+    ],
+    ids=str,
+)
+def test_unsupported_blocks_fail_explicitly(block: ContentBlock) -> None:
+    """Blocks Anthropic has no block for fail without leaking the source."""
+    connection, client = _connection()
+    message = ChatMessage.user([TextBlock(text="hi"), block])
+
+    with pytest.raises(UnsupportedContentBlockError) as error:
+        connection.chat([message], model="claude-sonnet-4-5")
+
+    text = str(error.value)
+    assert text.startswith("Anthropic cannot send a")
+    assert f" {block.type} block" in text
+    assert "secret" not in text
+    assert BASE64 not in text
+    client.messages.create.assert_not_called()
+
+
[email protected]("kind", ["assistant", "tool"])
+def test_media_outside_user_messages_fails(kind: str) -> None:
+    """Media in assistant messages and tool results fails explicitly."""
+    blocks = [TextBlock(text="see"), ImageBlock.from_url("image/png", URL)]
+    message = (
+        ChatMessage.assistant(blocks)
+        if kind == "assistant"
+        else ChatMessage.tool(ToolResultBlock(call_id="call-1", 
blocks=tuple(blocks)))
+    )
+
+    with pytest.raises(
+        UnsupportedContentBlockError, match="only user messages can carry 
media"
+    ):
+        _chat(ChatMessage.user("hi"), message)
+
+
+def test_invalid_base64_text_document_fails() -> None:
+    """Undecodable plain-text data fails with ValueError, not as 
unsupported."""
+    message = ChatMessage.user([DocumentBlock.from_base64("text/plain", "not 
base64!")])
+
+    with pytest.raises(ValueError, match="could not be decoded") as error:
+        _chat(message)
+
+    assert not isinstance(error.value, UnsupportedContentBlockError)
diff --git 
a/python/flink_agents/integrations/chat_models/tests/test_text_only_providers_reject_media.py
 
b/python/flink_agents/integrations/chat_models/tests/test_text_only_providers_reject_media.py
index a11aea10..0cef1641 100644
--- 
a/python/flink_agents/integrations/chat_models/tests/test_text_only_providers_reject_media.py
+++ 
b/python/flink_agents/integrations/chat_models/tests/test_text_only_providers_reject_media.py
@@ -33,9 +33,6 @@ from flink_agents.api.chat_message import (
     UnsupportedContentBlockError,
 )
 from flink_agents.api.chat_models.chat_model import BaseChatModelConnection
-from flink_agents.integrations.chat_models.anthropic.anthropic_chat_model 
import (
-    AnthropicChatModelConnection,
-)
 from flink_agents.integrations.chat_models.dashscope_chat_model import (
     DashScopeChatModelConnection,
 )
@@ -44,7 +41,6 @@ from 
flink_agents.integrations.chat_models.watsonx.watsonx_chat_model import (
 )
 
 CONNECTIONS: Dict[str, Callable[[], BaseChatModelConnection]] = {
-    "Anthropic": lambda: AnthropicChatModelConnection(api_key="fake-key"),
     "DashScope": lambda: DashScopeChatModelConnection(api_key="fake-key"),
     "IBM watsonx.ai": lambda: WatsonxChatModelConnection(
         url="https://us-south.ml.cloud.ibm.com";,

Reply via email to