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 da4553e80 [api][java][python] Add raw-bytes factories for media blocks 
(#1207)
da4553e80 is described below

commit da4553e80b0715c7000d2a5c637b52cee5ac4e93
Author: Wenjin Xie <[email protected]>
AuthorDate: Thu Oct 8 14:14:55 2026 +0800

    [api][java][python] Add raw-bytes factories for media blocks (#1207)
    
    Generated-by: Codex CLI 0.153.4 (GPT-6)
    
    Co-authored-by: Codex <[email protected]>
---
 .../flink/agents/api/chat/messages/AudioBlock.java |  15 ++-
 .../agents/api/chat/messages/Base64Source.java     |   9 ++
 .../agents/api/chat/messages/DocumentBlock.java    |  15 ++-
 .../flink/agents/api/chat/messages/ImageBlock.java |  15 ++-
 .../flink/agents/api/chat/messages/MediaBlock.java |  10 +-
 .../flink/agents/api/chat/messages/VideoBlock.java |  15 ++-
 .../agents/api/CrossLanguageEventSnapshotTest.java |  41 ++++++++
 .../api/chat/messages/MediaBlockBytesTest.java     | 113 +++++++++++++++++++++
 docs/content/docs/development/chat_models.md       |  43 +++++++-
 .../java/raw_media_chat_request_event.json         |  42 ++++++++
 .../python/raw_media_chat_request_event.json       |  63 ++++++++++++
 .../OpenAIChatCompletionsMultimodalTest.java       |  31 ++++++
 python/flink_agents/api/chat_message.py            |  27 ++++-
 python/flink_agents/api/tests/test_chat_message.py |  71 +++++++++++++
 .../tests/test_cross_language_event_snapshots.py   |  50 ++++++++-
 .../openai/tests/test_openai_multimodal.py         |  30 ++++++
 16 files changed, 574 insertions(+), 16 deletions(-)

diff --git 
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/AudioBlock.java 
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/AudioBlock.java
index 503a9b611..41d78c32d 100644
--- 
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/AudioBlock.java
+++ 
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/AudioBlock.java
@@ -48,7 +48,20 @@ public final class AudioBlock extends MediaBlock {
         super(mediaType, source, name, sizeBytes, sha256);
     }
 
-    /** Creates an audio block carrying an inline base64 payload. */
+    /**
+     * Creates a block from non-empty raw bytes, encoded as standard Base64 
without line wrapping.
+     * The caller's array is not retained; subsequent changes to it do not 
affect the block.
+     *
+     * @throws IllegalArgumentException if the media type or data is null or 
empty
+     */
+    public static AudioBlock fromBytes(String mediaType, byte[] data) {
+        return new AudioBlock(mediaType, Base64Source.fromBytes(data), null, 
null, null);
+    }
+
+    /**
+     * Creates a block from a non-empty, already Base64-encoded string. The 
string is preserved
+     * without encoding or validating Base64 syntax.
+     */
     public static AudioBlock fromBase64(String mediaType, String data) {
         return new AudioBlock(mediaType, new Base64Source(data), null, null, 
null);
     }
diff --git 
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/Base64Source.java 
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/Base64Source.java
index 124036139..f279d8887 100644
--- 
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/Base64Source.java
+++ 
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/Base64Source.java
@@ -22,6 +22,7 @@ import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonProperty;
 import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
 
+import java.util.Base64;
 import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.Objects;
@@ -46,6 +47,14 @@ public final class Base64Source extends MediaSource {
         return data;
     }
 
+    /** Encodes raw bytes immediately, without retaining the caller's array. */
+    static Base64Source fromBytes(byte[] data) {
+        if (data == null || data.length == 0) {
+            throw new IllegalArgumentException("A raw media payload requires 
non-empty bytes.");
+        }
+        return new Base64Source(Base64.getEncoder().encodeToString(data));
+    }
+
     @Override
     public String getType() {
         return "base64";
diff --git 
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/DocumentBlock.java
 
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/DocumentBlock.java
index 6991355d0..87c57b733 100644
--- 
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/DocumentBlock.java
+++ 
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/DocumentBlock.java
@@ -48,7 +48,20 @@ public final class DocumentBlock extends MediaBlock {
         super(mediaType, source, name, sizeBytes, sha256);
     }
 
-    /** Creates a document block carrying an inline base64 payload. */
+    /**
+     * Creates a block from non-empty raw bytes, encoded as standard Base64 
without line wrapping.
+     * The caller's array is not retained; subsequent changes to it do not 
affect the block.
+     *
+     * @throws IllegalArgumentException if the media type or data is null or 
empty
+     */
+    public static DocumentBlock fromBytes(String mediaType, byte[] data) {
+        return new DocumentBlock(mediaType, Base64Source.fromBytes(data), 
null, null, null);
+    }
+
+    /**
+     * Creates a block from a non-empty, already Base64-encoded string. The 
string is preserved
+     * without encoding or validating Base64 syntax.
+     */
     public static DocumentBlock fromBase64(String mediaType, String data) {
         return new DocumentBlock(mediaType, new Base64Source(data), null, 
null, null);
     }
diff --git 
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/ImageBlock.java 
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/ImageBlock.java
index 9816f6ddb..7f01072b0 100644
--- 
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/ImageBlock.java
+++ 
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/ImageBlock.java
@@ -48,7 +48,20 @@ public final class ImageBlock extends MediaBlock {
         super(mediaType, source, name, sizeBytes, sha256);
     }
 
-    /** Creates an image block carrying an inline base64 payload. */
+    /**
+     * Creates a block from non-empty raw bytes, encoded as standard Base64 
without line wrapping.
+     * The caller's array is not retained; subsequent changes to it do not 
affect the block.
+     *
+     * @throws IllegalArgumentException if the media type or data is null or 
empty
+     */
+    public static ImageBlock fromBytes(String mediaType, byte[] data) {
+        return new ImageBlock(mediaType, Base64Source.fromBytes(data), null, 
null, null);
+    }
+
+    /**
+     * Creates a block from a non-empty, already Base64-encoded string. The 
string is preserved
+     * without encoding or validating Base64 syntax.
+     */
     public static ImageBlock fromBase64(String mediaType, String data) {
         return new ImageBlock(mediaType, new Base64Source(data), null, null, 
null);
     }
diff --git 
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/MediaBlock.java 
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/MediaBlock.java
index 28d351aab..a462f7b5e 100644
--- 
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/MediaBlock.java
+++ 
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/MediaBlock.java
@@ -32,11 +32,11 @@ import java.util.Objects;
  * (RFC 6838; historically called a MIME type), and the payload location is a 
typed {@link
  * MediaSource}.
  *
- * <p>Media blocks are immutable, and every construction path — the {@code 
fromBase64}/{@code
- * fromUrl} factories, the full constructors, and Jackson deserialization — 
runs the same
- * validation. Which kind of source a block carries is structural: there is 
exactly one {@code
- * source}, and its {@code type} discriminator says whether it is an inline 
{@link Base64Source} or
- * an externally managed {@link UrlSource}.
+ * <p>Media blocks are immutable, and every construction path — the {@code 
fromBytes}/{@code
+ * fromBase64}/{@code fromUrl} factories, the full constructors, and Jackson 
deserialization — runs
+ * the same validation. Which kind of source a block carries is structural: 
there is exactly one
+ * {@code source}, and its {@code type} discriminator says whether it is an 
inline {@link
+ * Base64Source} or an externally managed {@link UrlSource}.
  *
  * <p>The optional {@code name}/{@code sizeBytes}/{@code sha256} metadata also 
serves the Event Log,
  * which records media metadata instead of payload bytes — see {@link 
#sanitize()}.
diff --git 
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/VideoBlock.java 
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/VideoBlock.java
index 56e9f9e7a..1b9083286 100644
--- 
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/VideoBlock.java
+++ 
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/VideoBlock.java
@@ -48,7 +48,20 @@ public final class VideoBlock extends MediaBlock {
         super(mediaType, source, name, sizeBytes, sha256);
     }
 
-    /** Creates a video block carrying an inline base64 payload. */
+    /**
+     * Creates a block from non-empty raw bytes, encoded as standard Base64 
without line wrapping.
+     * The caller's array is not retained; subsequent changes to it do not 
affect the block.
+     *
+     * @throws IllegalArgumentException if the media type or data is null or 
empty
+     */
+    public static VideoBlock fromBytes(String mediaType, byte[] data) {
+        return new VideoBlock(mediaType, Base64Source.fromBytes(data), null, 
null, null);
+    }
+
+    /**
+     * Creates a block from a non-empty, already Base64-encoded string. The 
string is preserved
+     * without encoding or validating Base64 syntax.
+     */
     public static VideoBlock fromBase64(String mediaType, String data) {
         return new VideoBlock(mediaType, new Base64Source(data), null, null, 
null);
     }
diff --git 
a/api/src/test/java/org/apache/flink/agents/api/CrossLanguageEventSnapshotTest.java
 
b/api/src/test/java/org/apache/flink/agents/api/CrossLanguageEventSnapshotTest.java
index 475669570..79563880b 100644
--- 
a/api/src/test/java/org/apache/flink/agents/api/CrossLanguageEventSnapshotTest.java
+++ 
b/api/src/test/java/org/apache/flink/agents/api/CrossLanguageEventSnapshotTest.java
@@ -21,8 +21,12 @@ package org.apache.flink.agents.api;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.apache.flink.agents.api.agents.OutputSchema;
+import org.apache.flink.agents.api.chat.messages.AudioBlock;
 import org.apache.flink.agents.api.chat.messages.ChatMessage;
+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.MessageRole;
+import org.apache.flink.agents.api.chat.messages.VideoBlock;
 import org.apache.flink.agents.api.context.MemoryObject;
 import org.apache.flink.agents.api.context.MemoryRef;
 import org.apache.flink.agents.api.event.AgentRunBeginEvent;
@@ -195,6 +199,43 @@ class CrossLanguageEventSnapshotTest {
 
     // ── ChatRequestEvent ───────────────────────────────────────────────────
 
+    private static ChatRequestEvent buildRawMediaChatRequestEvent() {
+        byte[] data = {(byte) 0xff, 0, (byte) 0x80, (byte) 0xfb};
+        Map<String, Object> attrs = new LinkedHashMap<>();
+        attrs.put("model", "test-model");
+        attrs.put(
+                "messages",
+                List.of(
+                        ChatMessage.user(
+                                List.of(
+                                        ImageBlock.fromBytes("image/png", 
data),
+                                        AudioBlock.fromBytes("audio/wav", 
data),
+                                        VideoBlock.fromBytes("video/mp4", 
data),
+                                        
DocumentBlock.fromBytes("application/pdf", data)))));
+        return new ChatRequestEvent(FIXED_EVENT_ID, attrs);
+    }
+
+    @Test
+    void regenerateRawMediaChatRequestEventJavaSnapshot() throws Exception {
+        assumeTrue(regenerateRequested(), "Set -Dregenerate.snapshots=true to 
refresh.");
+        writeJavaSnapshot("raw_media_chat_request_event.json", 
buildRawMediaChatRequestEvent());
+    }
+
+    @Test
+    void rawMediaChatRequestEventJavaSnapshotIsStable() throws Exception {
+        assertJavaSnapshotStable(
+                "raw_media_chat_request_event.json", 
buildRawMediaChatRequestEvent());
+    }
+
+    @Test
+    void javaCanDeserializeRawMediaChatRequestEventFromPythonSnapshot() throws 
Exception {
+        ChatRequestEvent event =
+                
ChatRequestEvent.fromEvent(readPythonSnapshot("raw_media_chat_request_event.json"));
+        assertEquals(
+                
buildRawMediaChatRequestEvent().getMessages().get(0).getBlocks(),
+                event.getMessages().get(0).getBlocks());
+    }
+
     private static ChatRequestEvent buildChatRequestEvent() {
         Map<String, Object> attrs = new LinkedHashMap<>();
         attrs.put("model", "test-model");
diff --git 
a/api/src/test/java/org/apache/flink/agents/api/chat/messages/MediaBlockBytesTest.java
 
b/api/src/test/java/org/apache/flink/agents/api/chat/messages/MediaBlockBytesTest.java
new file mode 100644
index 000000000..c24590c34
--- /dev/null
+++ 
b/api/src/test/java/org/apache/flink/agents/api/chat/messages/MediaBlockBytesTest.java
@@ -0,0 +1,113 @@
+/*
+ * 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.api.chat.messages;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+import java.util.function.BiFunction;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Raw-byte factory contracts shared by every media modality. */
+class MediaBlockBytesTest {
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    static Stream<BiFunction<String, byte[], MediaBlock>> factories() {
+        return Stream.of(
+                ImageBlock::fromBytes,
+                AudioBlock::fromBytes,
+                VideoBlock::fromBytes,
+                DocumentBlock::fromBytes);
+    }
+
+    @ParameterizedTest
+    @MethodSource("factories")
+    void preservesBinaryPayloadAndExistingWireShape(BiFunction<String, byte[], 
MediaBlock> factory)
+            throws Exception {
+        byte[] allBytes = new byte[256];
+        for (int i = 0; i < allBytes.length; i++) {
+            allBytes[i] = (byte) i;
+        }
+        // Exercise both padding lengths, no padding, non-UTF-8 data and no 
line wrapping.
+        for (byte[] data :
+                List.of(
+                        new byte[] {(byte) 0xff},
+                        new byte[] {(byte) 0xff, 0},
+                        new byte[] {(byte) 0xff, 0, (byte) 0x80},
+                        allBytes)) {
+            MediaBlock block = factory.apply("application/octet-stream", data);
+            String encoded = ((Base64Source) block.getSource()).getData();
+            assertThat(encoded).doesNotContain("\n", "\r");
+            assertThat(Base64.getDecoder().decode(encoded)).isEqualTo(data);
+            assertThat(encoded).hasSize(4 * ((data.length + 2) / 3));
+            assertThat(block.getSource().getSizeBytes()).isEqualTo((long) 
data.length);
+            assertThat(block.getSizeBytes()).isNull();
+            ChatMessage message = ChatMessage.user(List.of(block));
+            Map<String, Object> expected =
+                    Map.of(
+                            "type",
+                            block.getType(),
+                            "media_type",
+                            "application/octet-stream",
+                            "source",
+                            Map.of("type", "base64", "data", encoded));
+            JsonNode serialized = MAPPER.valueToTree(block);
+            assertThat(serialized).isEqualTo(MAPPER.valueToTree(expected));
+            assertThat(
+                            
MAPPER.readValue(MAPPER.writeValueAsString(message), ChatMessage.class)
+                                    .getBlocks())
+                    .containsExactly(block);
+            ChatMessage fromMap = new ChatMessage();
+            fromMap.setBlocksFromMaps(List.of(expected));
+            assertThat(fromMap.getBlocks()).containsExactly(block);
+            assertThat(block.toString()).doesNotContain(encoded);
+            
assertThat(MAPPER.writeValueAsString(block.sanitize())).doesNotContain(encoded);
+        }
+    }
+
+    @ParameterizedTest
+    @MethodSource("factories")
+    void doesNotRetainCallerArray(BiFunction<String, byte[], MediaBlock> 
factory) {
+        byte[] data = {(byte) 0xff, 0, (byte) 0x80};
+        MediaBlock block = factory.apply("application/octet-stream", data);
+        Arrays.fill(data, (byte) 1);
+        assertThat(((Base64Source) 
block.getSource()).getData()).isEqualTo("/wCA");
+    }
+
+    @ParameterizedTest
+    @MethodSource("factories")
+    void rejectsMissingDataAndMediaType(BiFunction<String, byte[], MediaBlock> 
factory) {
+        assertThatThrownBy(() -> factory.apply("image/png", null))
+                .isInstanceOf(IllegalArgumentException.class);
+        assertThatThrownBy(() -> factory.apply("image/png", new byte[0]))
+                .isInstanceOf(IllegalArgumentException.class);
+        assertThatThrownBy(() -> factory.apply(null, new byte[] {1}))
+                .isInstanceOf(IllegalArgumentException.class);
+        assertThatThrownBy(() -> factory.apply("", new byte[] {1}))
+                .isInstanceOf(IllegalArgumentException.class);
+    }
+}
diff --git a/docs/content/docs/development/chat_models.md 
b/docs/content/docs/development/chat_models.md
index ab540e41c..b4877c924 100644
--- a/docs/content/docs/development/chat_models.md
+++ b/docs/content/docs/development/chat_models.md
@@ -92,11 +92,44 @@ ChatMessage message = ChatMessage.user(List.of(
 {{< /tabs >}}
 
 Each media block requires a MIME type such as `image/png` and a media source.
-Use `from_url` (Python) or `fromUrl` (Java) for an external URL, as above. For
-inline data, use `from_base64(media_type, data)` or `fromBase64(mediaType, 
data)`
-with an already Base64-encoded string. The framework does not encode the data 
or
-verify that its contents match the declared media type. Choose a concrete block
-such as `ImageBlock`, rather than constructing the shared `MediaBlock` base 
class.
+Use `from_url` (Python) or `fromUrl` (Java) for an externally managed URL, as 
above.
+For inline data, all four media block types provide two separate entry points:
+
+- `from_bytes(media_type, data)` (Python) or `fromBytes(mediaType, data)` 
(Java)
+  accepts non-empty raw `bytes` or `byte[]` and encodes them as standard Base64
+  without line wrapping at construction. Java does not retain the caller's 
array.
+- `from_base64(media_type, data)` or `fromBase64(mediaType, data)` accepts a
+  non-empty, already Base64-encoded string. It preserves the string without
+  encoding it again or validating its Base64 syntax.
+
+{{< tabs "Inline media" >}}
+{{< tab "Python" >}}
+```python
+from pathlib import Path
+from flink_agents.api.chat_message import ImageBlock
+
+image = ImageBlock.from_bytes("image/png", Path("image.png").read_bytes(), 
name="image.png")
+# If a service already returned Base64, pass that string directly.
+encoded_image = ImageBlock.from_base64("image/png", base64_from_service)
+```
+{{< /tab >}}
+{{< tab "Java" >}}
+```java
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.flink.agents.api.chat.messages.ImageBlock;
+
+ImageBlock image = ImageBlock.fromBytes("image/png", 
Files.readAllBytes(Path.of("image.png")));
+// If a service already returned Base64, pass that string directly.
+ImageBlock encodedImage = ImageBlock.fromBase64("image/png", 
base64FromService);
+```
+{{< /tab >}}
+{{< /tabs >}}
+
+The framework does not verify that the contents match the declared media type.
+Choose a concrete block such as `ImageBlock`, rather than constructing the 
shared
+`MediaBlock` base class. Both inline factories produce the same Base64 source
+representation for serialization and provider conversion.
 
 Media blocks also accept optional metadata: `name` for a file name, 
`size_bytes`
 for the media size in bytes, and `sha256` for a content checksum.
diff --git 
a/e2e-test/cross-language-event-snapshots/java/raw_media_chat_request_event.json
 
b/e2e-test/cross-language-event-snapshots/java/raw_media_chat_request_event.json
new file mode 100644
index 000000000..9c6e14119
--- /dev/null
+++ 
b/e2e-test/cross-language-event-snapshots/java/raw_media_chat_request_event.json
@@ -0,0 +1,42 @@
+{
+  "id" : "00000000-0000-0000-0000-000000000001",
+  "attributes" : {
+    "messages" : [ {
+      "role" : "user",
+      "blocks" : [ {
+        "type" : "image",
+        "media_type" : "image/png",
+        "source" : {
+          "type" : "base64",
+          "data" : "/wCA+w=="
+        }
+      }, {
+        "type" : "audio",
+        "media_type" : "audio/wav",
+        "source" : {
+          "type" : "base64",
+          "data" : "/wCA+w=="
+        }
+      }, {
+        "type" : "video",
+        "media_type" : "video/mp4",
+        "source" : {
+          "type" : "base64",
+          "data" : "/wCA+w=="
+        }
+      }, {
+        "type" : "document",
+        "media_type" : "application/pdf",
+        "source" : {
+          "type" : "base64",
+          "data" : "/wCA+w=="
+        }
+      } ],
+      "tool_calls" : [ ],
+      "extra_args" : { }
+    } ],
+    "model" : "test-model"
+  },
+  "attachments" : { },
+  "type" : "_chat_request_event"
+}
diff --git 
a/e2e-test/cross-language-event-snapshots/python/raw_media_chat_request_event.json
 
b/e2e-test/cross-language-event-snapshots/python/raw_media_chat_request_event.json
new file mode 100644
index 000000000..ac48aa92a
--- /dev/null
+++ 
b/e2e-test/cross-language-event-snapshots/python/raw_media_chat_request_event.json
@@ -0,0 +1,63 @@
+{
+  "id": "00000000-0000-0000-0000-000000000001",
+  "type": "_chat_request_event",
+  "attributes": {
+    "model": "test-model",
+    "messages": [
+      {
+        "role": "user",
+        "blocks": [
+          {
+            "media_type": "image/png",
+            "source": {
+              "type": "base64",
+              "data": "/wCA+w=="
+            },
+            "name": null,
+            "size_bytes": null,
+            "sha256": null,
+            "type": "image"
+          },
+          {
+            "media_type": "audio/wav",
+            "source": {
+              "type": "base64",
+              "data": "/wCA+w=="
+            },
+            "name": null,
+            "size_bytes": null,
+            "sha256": null,
+            "type": "audio"
+          },
+          {
+            "media_type": "video/mp4",
+            "source": {
+              "type": "base64",
+              "data": "/wCA+w=="
+            },
+            "name": null,
+            "size_bytes": null,
+            "sha256": null,
+            "type": "video"
+          },
+          {
+            "media_type": "application/pdf",
+            "source": {
+              "type": "base64",
+              "data": "/wCA+w=="
+            },
+            "name": null,
+            "size_bytes": null,
+            "sha256": null,
+            "type": "document"
+          }
+        ],
+        "tool_calls": [],
+        "extra_args": {}
+      }
+    ],
+    "prompt_args": {},
+    "output_schema": null
+  },
+  "attachments": {}
+}
diff --git 
a/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIChatCompletionsMultimodalTest.java
 
b/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIChatCompletionsMultimodalTest.java
index be6266a0e..78549c3ee 100644
--- 
a/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIChatCompletionsMultimodalTest.java
+++ 
b/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIChatCompletionsMultimodalTest.java
@@ -54,6 +54,37 @@ class OpenAIChatCompletionsMultimodalTest {
                 
.valueToTree(OpenAIChatCompletionsUtils.convertToOpenAIMessage(message));
     }
 
+    @Test
+    void rawBytesPreservedInProviderContentParts() {
+        byte[] data = {(byte) 0xff, 0, (byte) 0x80, (byte) 0xfb};
+        String encoded = "/wCA+w==";
+        ChatMessage raw =
+                ChatMessage.user(
+                        List.of(
+                                ImageBlock.fromBytes("image/png", data),
+                                AudioBlock.fromBytes("audio/wav", data),
+                                DocumentBlock.fromBytes("application/pdf", 
data)));
+        ChatMessage existing =
+                ChatMessage.user(
+                        List.of(
+                                ImageBlock.fromBase64("image/png", encoded),
+                                AudioBlock.fromBase64("audio/wav", encoded),
+                                DocumentBlock.fromBase64("application/pdf", 
encoded)));
+        assertThat(wire(raw)).isEqualTo(wire(existing));
+        JsonNode parts = wire(raw).get("content");
+        assertThat(parts.get(0).at("/image_url/url").asText())
+                .isEqualTo("data:image/png;base64," + encoded);
+        
assertThat(parts.get(1).at("/input_audio/data").asText()).isEqualTo(encoded);
+        assertThat(parts.get(2).at("/file/file_data").asText())
+                .isEqualTo("data:application/pdf;base64," + encoded);
+        assertThatThrownBy(
+                        () ->
+                                wire(
+                                        ChatMessage.user(
+                                                
List.of(VideoBlock.fromBytes("video/mp4", data)))))
+                .isInstanceOf(UnsupportedContentBlockException.class);
+    }
+
     @Test
     @DisplayName("A text-only user message keeps plain string content")
     void testTextOnlyUserMessageKeepsStringContent() {
diff --git a/python/flink_agents/api/chat_message.py 
b/python/flink_agents/api/chat_message.py
index 7563b2add..171ed180d 100644
--- a/python/flink_agents/api/chat_message.py
+++ b/python/flink_agents/api/chat_message.py
@@ -15,6 +15,7 @@
 #  See the License for the specific language governing permissions and
 # limitations under the License.
 
#################################################################################
+import base64
 from enum import Enum
 from typing import Any, Dict, List, Literal, Sequence
 
@@ -135,9 +136,33 @@ class MediaBlock(BaseModel):
 
     @classmethod
     def from_base64(cls, media_type: str, data: str, **kwargs: Any) -> Self:
-        """Create a block carrying an inline base64 payload."""
+        """Create a block from a non-empty, already Base64-encoded string.
+
+        The string is preserved without encoding or validating Base64 syntax.
+        """
         return cls(media_type=media_type, source=Base64Source(data=data), 
**kwargs)
 
+    @classmethod
+    def from_bytes(cls, media_type: str, data: bytes, **kwargs: Any) -> Self:
+        """Create a block from non-empty raw bytes using standard Base64.
+
+        Encoding happens once at construction, without line wrapping. Optional
+        metadata is passed through just as for ``from_base64``.
+
+        Raises:
+            TypeError: If data is not bytes.
+            ValueError: If data is empty or media fields are invalid.
+        """
+        if not isinstance(data, bytes):
+            msg = "A raw media payload must be bytes."
+            raise TypeError(msg)
+        if not data:
+            msg = "A raw media payload must not be empty."
+            raise ValueError(msg)
+        return cls.from_base64(
+            media_type, base64.b64encode(data).decode("ascii"), **kwargs
+        )
+
     @classmethod
     def from_url(cls, media_type: str, url: str, **kwargs: Any) -> Self:
         """Create a block referencing an externally managed URL or file URI."""
diff --git a/python/flink_agents/api/tests/test_chat_message.py 
b/python/flink_agents/api/tests/test_chat_message.py
index ca8247057..5fedf59b0 100644
--- a/python/flink_agents/api/tests/test_chat_message.py
+++ b/python/flink_agents/api/tests/test_chat_message.py
@@ -15,6 +15,8 @@
 #  See the License for the specific language governing permissions and
 # limitations under the License.
 
#################################################################################
+import base64
+
 import pytest
 from pydantic import ValidationError
 
@@ -24,6 +26,7 @@ from flink_agents.api.chat_message import (
     ChatMessage,
     DocumentBlock,
     ImageBlock,
+    MediaBlock,
     MessageRole,
     TextBlock,
     UrlSource,
@@ -318,3 +321,71 @@ def test_factories_and_text_projection() -> None:
     empty.set_text("replaced")
     assert empty.text == "replaced"
     assert str(ChatMessage.user("hi")) == "user: hi"
+
+
+_MEDIA_TYPES = [
+    (ImageBlock, "image/png"),
+    (AudioBlock, "audio/wav"),
+    (VideoBlock, "video/mp4"),
+    (DocumentBlock, "application/pdf"),
+]
+
+
[email protected](("block_type", "media_type"), _MEDIA_TYPES)
[email protected](
+    "data", [b"\xff", b"\xff\x00", b"\xff\x00\x80", bytes(range(256))]
+)
+def test_from_bytes_preserves_binary_and_wire_contract(
+    block_type: type[MediaBlock], media_type: str, data: bytes
+) -> None:
+    """Exercise padding, non-UTF-8 bytes, and payloads longer than a MIME 
line."""
+    block = block_type.from_bytes(media_type, data)
+    encoded = block.source.data
+    assert "\n" not in encoded
+    assert "\r" not in encoded
+    assert base64.b64decode(encoded, validate=True) == data
+    assert len(encoded) == 4 * ((len(data) + 2) // 3)
+    assert block.source.size_bytes == len(data)
+    assert block.size_bytes is None
+    assert block == block_type.from_base64(
+        media_type, base64.b64encode(data).decode("ascii")
+    )
+    message = ChatMessage.user([block])
+    assert ChatMessage.model_validate_json(message.model_dump_json()) == 
message
+    assert message.model_dump(mode="json", exclude_none=True)["blocks"] == [
+        {
+            "type": block.type,
+            "media_type": media_type,
+            "source": {"type": "base64", "data": encoded},
+        }
+    ]
+    assert encoded not in repr(message)
+    assert encoded not in str(block)
+
+
[email protected](("block_type", "media_type"), _MEDIA_TYPES)
[email protected](
+    "data", [None, "aGk=", 3, [1, 2], bytearray(b"hi"), memoryview(b"hi")]
+)
+def test_from_bytes_rejects_non_bytes(block_type, media_type, data) -> None:
+    with pytest.raises(TypeError, match="must be bytes"):
+        block_type.from_bytes(media_type, data)
+
+
[email protected](("block_type", "media_type"), _MEDIA_TYPES)
+def test_from_bytes_validation_and_metadata(block_type, media_type) -> None:
+    with pytest.raises(ValueError, match="must not be empty"):
+        block_type.from_bytes(media_type, b"")
+    for invalid_media_type in (None, "", 3):
+        with pytest.raises(ValidationError):
+            block_type.from_bytes(invalid_media_type, b"hi")
+    with pytest.raises(ValidationError):
+        block_type.from_bytes(media_type, b"hi", size_bytes=-1)
+    metadata = {"name": "sample", "size_bytes": 2, "sha256": "caller-supplied"}
+    assert block_type.from_bytes(
+        media_type, b"hi", **metadata
+    ) == block_type.from_base64(media_type, "aGk=", **metadata)
+    # Existing Base64 factories neither encode again nor start validating 
syntax.
+    assert (
+        block_type.from_base64(media_type, "not base64!").source.data == "not 
base64!"
+    )
diff --git 
a/python/flink_agents/api/tests/test_cross_language_event_snapshots.py 
b/python/flink_agents/api/tests/test_cross_language_event_snapshots.py
index c14bf0f91..6cc98608a 100644
--- a/python/flink_agents/api/tests/test_cross_language_event_snapshots.py
+++ b/python/flink_agents/api/tests/test_cross_language_event_snapshots.py
@@ -25,7 +25,14 @@ from uuid import UUID
 
 import pytest
 
-from flink_agents.api.chat_message import ChatMessage, MessageRole
+from flink_agents.api.chat_message import (
+    AudioBlock,
+    ChatMessage,
+    DocumentBlock,
+    ImageBlock,
+    MessageRole,
+    VideoBlock,
+)
 from flink_agents.api.events.chat_event import ChatRequestEvent, 
ChatResponseEvent
 from flink_agents.api.events.context_retrieval_event import (
     ContextRetrievalRequestEvent,
@@ -141,6 +148,47 @@ def 
test_python_can_deserialize_output_event_from_java_snapshot() -> None:
 # ── ChatRequestEvent ────────────────────────────────────────────────────
 
 
+def _build_raw_media_chat_request_event() -> ChatRequestEvent:
+    data = b"\xff\x00\x80\xfb"
+    event = ChatRequestEvent(
+        model="test-model",
+        messages=[
+            ChatMessage.user(
+                [
+                    ImageBlock.from_bytes("image/png", data),
+                    AudioBlock.from_bytes("audio/wav", data),
+                    VideoBlock.from_bytes("video/mp4", data),
+                    DocumentBlock.from_bytes("application/pdf", data),
+                ]
+            )
+        ],
+    )
+    return _force_id(event, _FIXED_EVENT_ID)
+
+
+def test_regenerate_raw_media_chat_request_event_python_snapshot() -> None:
+    if not _regenerate_enabled():
+        pytest.skip("Set REGENERATE_SNAPSHOTS=1 to refresh.")
+    _write_python_snapshot(
+        "raw_media_chat_request_event.json", 
_build_raw_media_chat_request_event()
+    )
+
+
+def test_raw_media_chat_request_event_python_snapshot_is_stable() -> None:
+    _assert_python_snapshot_stable(
+        "raw_media_chat_request_event.json", 
_build_raw_media_chat_request_event()
+    )
+
+
+def 
test_python_can_deserialize_raw_media_chat_request_event_from_java_snapshot() 
-> (
+    None
+):
+    event = ChatRequestEvent.from_event(
+        _read_java_snapshot("raw_media_chat_request_event.json")
+    )
+    assert event.messages == _build_raw_media_chat_request_event().messages
+
+
 def _build_chat_request_event() -> ChatRequestEvent:
     event = ChatRequestEvent(
         model="test-model",
diff --git 
a/python/flink_agents/integrations/chat_models/openai/tests/test_openai_multimodal.py
 
b/python/flink_agents/integrations/chat_models/openai/tests/test_openai_multimodal.py
index a2895cecf..a2f6c924e 100644
--- 
a/python/flink_agents/integrations/chat_models/openai/tests/test_openai_multimodal.py
+++ 
b/python/flink_agents/integrations/chat_models/openai/tests/test_openai_multimodal.py
@@ -141,3 +141,33 @@ def test_media_outside_user_messages_fails(role: 
MessageRole) -> None:
 
     with pytest.raises(UnsupportedContentBlockError, match="only user 
messages"):
         convert_to_openai_message(message)
+
+
+def test_raw_bytes_preserved_in_provider_content_parts() -> None:
+    """No extra encoding or text decoding occurs when converting binary 
media."""
+    data = b"\xff\x00\x80\xfb"
+    raw = ChatMessage.user(
+        [
+            ImageBlock.from_bytes("image/png", data),
+            AudioBlock.from_bytes("audio/wav", data),
+            DocumentBlock.from_bytes("application/pdf", data),
+        ]
+    )
+    encoded = "/wCA+w=="
+    existing = ChatMessage.user(
+        [
+            ImageBlock.from_base64("image/png", encoded),
+            AudioBlock.from_base64("audio/wav", encoded),
+            DocumentBlock.from_base64("application/pdf", encoded),
+        ]
+    )
+    converted = convert_to_openai_message(raw)
+    assert converted == convert_to_openai_message(existing)
+    parts = converted["content"]
+    assert parts[0]["image_url"]["url"] == f"data:image/png;base64,{encoded}"
+    assert parts[1]["input_audio"]["data"] == encoded
+    assert parts[2]["file"]["file_data"] == 
f"data:application/pdf;base64,{encoded}"
+    with pytest.raises(UnsupportedContentBlockError):
+        convert_to_openai_message(
+            ChatMessage.user([VideoBlock.from_bytes("video/mp4", data)])
+        )

Reply via email to