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 1618728b [integration][java][python] Send multimodal user messages 
through OpenAI Chat Completions (#1164)
1618728b is described below

commit 1618728b8d2b0ad9f06704dadf0637552efa431e
Author: Edson <[email protected]>
AuthorDate: Tue Sep 29 04:14:52 2026 -0400

    [integration][java][python] Send multimodal user messages through OpenAI 
Chat Completions (#1164)
    
    Generated-by: Claude Code 2.1.259 (Claude Opus 5.5)
---
 .../messages/UnsupportedContentBlockException.java |  53 ++++++
 docs/content/docs/development/chat_models.md       |  26 ++-
 .../openai/OpenAIChatCompletionsUtils.java         | 146 ++++++++++++++-
 .../OpenAIChatCompletionsMultimodalLiveTest.java   | 197 +++++++++++++++++++++
 .../OpenAIChatCompletionsMultimodalTest.java       | 161 +++++++++++++++++
 python/flink_agents/api/chat_message.py            |  25 +++
 .../chat_models/openai/openai_utils.py             |  96 +++++++++-
 .../openai/tests/test_openai_multimodal.py         | 143 +++++++++++++++
 .../openai/tests/test_openai_multimodal_live.py    | 161 +++++++++++++++++
 9 files changed, 1000 insertions(+), 8 deletions(-)

diff --git 
a/api/src/main/java/org/apache/flink/agents/api/chat/messages/UnsupportedContentBlockException.java
 
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/UnsupportedContentBlockException.java
new file mode 100644
index 00000000..da58ccc4
--- /dev/null
+++ 
b/api/src/main/java/org/apache/flink/agents/api/chat/messages/UnsupportedContentBlockException.java
@@ -0,0 +1,53 @@
+/*
+ * 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;
+
+/**
+ * Thrown when a chat model integration cannot send a {@link ContentBlock} to 
its provider, such as
+ * a media type the provider does not accept or a media block in a message 
role that only takes
+ * text. Integrations throw it rather than silently dropping or converting the 
block.
+ *
+ * <p>Messages name the block type, media type and source type only, never the 
media payload or URL.
+ */
+public class UnsupportedContentBlockException extends IllegalArgumentException 
{
+
+    private static final long serialVersionUID = 1L;
+
+    public UnsupportedContentBlockException(String message) {
+        super(message);
+    }
+
+    /**
+     * Creates the exception for a block {@code provider} cannot send, with a 
message of the form
+     * "{provider} cannot send a(n) {type} block ({media type}, {source type} 
source): {reason}."
+     */
+    public static UnsupportedContentBlockException forBlock(
+            String provider, ContentBlock block, String reason) {
+        String type = block.getType();
+        String description =
+                ("aeiou".indexOf(type.charAt(0)) >= 0 ? "an " : "a ") + type + 
" block";
+        if (block instanceof MediaBlock) {
+            MediaBlock media = (MediaBlock) block;
+            description +=
+                    " (" + media.getMediaType() + ", " + 
media.getSource().getType() + " source)";
+        }
+        return new UnsupportedContentBlockException(
+                provider + " cannot send " + description + ": " + reason + 
".");
+    }
+}
diff --git a/docs/content/docs/development/chat_models.md 
b/docs/content/docs/development/chat_models.md
index b04fea20..11a1b8a8 100644
--- a/docs/content/docs/development/chat_models.md
+++ b/docs/content/docs/development/chat_models.md
@@ -60,9 +60,11 @@ order you want them presented. Supported block types are 
`TextBlock`, `ImageBloc
 `AudioBlock`, `VideoBlock`, and `DocumentBlock`. For example, a message can 
contain
 a question followed by an image:
 
-**Provider support:** Built-in provider integrations currently send only the 
text
-portion of a message. These examples construct multimodal messages; sending 
their
-media to a model requires a provider integration that supports those block 
types.
+**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](#multimodal-input) for what each block becomes. The other
+built-in integrations, including the OpenAI Responses integration, currently 
send
+only the text portion of a message.
 
 {{< tabs "Message content blocks" >}}
 
@@ -594,6 +596,8 @@ public class MyAgent extends Agent {
 
 {{< /tabs >}}
 
+Azure OpenAI sends media blocks the same way as the OpenAI Chat Completions 
API; see [Multimodal Input](#multimodal-input).
+
 #### Available Models
 
 Azure OpenAI supports OpenAI models deployed through your Azure subscription. 
Visit the [Azure OpenAI Models 
documentation](https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models)
 for the complete and up-to-date list of available models.
@@ -1003,6 +1007,20 @@ public class MyAgent extends Agent {
 
 {{< /tabs >}}
 
+#### Multimodal Input
+
+The Chat Completions connections (OpenAI, Azure OpenAI and vLLM) send the 
media blocks of a user message as content parts, in block order:
+
+| Block | Sent as |
+|---|---|
+| `TextBlock` | a `text` part |
+| `ImageBlock` | an `image_url` part: the URL, or a `data:` URI for Base64 
data |
+| `AudioBlock` | an `input_audio` part, for Base64 data in `audio/wav` or 
`audio/mpeg` (WAV or MP3) |
+| `DocumentBlock` | a `file` part: the Base64 data as a `data:` URI, with the 
block's `name` as the file name (`document` when unset). OpenAI accepts PDF 
documents only |
+| `VideoBlock` | not supported |
+
+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.
+
 #### Responses API
 
 {{< hint info >}}
@@ -1281,6 +1299,8 @@ public class MyAgent extends Agent {
 
 {{< /tabs >}}
 
+vLLM receives media blocks the same way as the OpenAI Chat Completions API; 
see [Multimodal Input](#multimodal-input). The model must be multimodal; see 
the [vLLM multimodal inputs 
docs](https://docs.vllm.ai/en/stable/features/multimodal_inputs/).
+
 #### Available Models
 
 A vLLM server serves the model(s) it was started with. Query `GET /v1/models` 
on the server to list them; the `model` value in the setup must match one of 
the returned names.
diff --git 
a/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIChatCompletionsUtils.java
 
b/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIChatCompletionsUtils.java
index 60b1f2f0..dd53038c 100644
--- 
a/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIChatCompletionsUtils.java
+++ 
b/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIChatCompletionsUtils.java
@@ -23,6 +23,10 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import com.openai.core.JsonValue;
 import com.openai.core.Timeout;
 import com.openai.models.chat.completions.ChatCompletionAssistantMessageParam;
+import com.openai.models.chat.completions.ChatCompletionContentPart;
+import com.openai.models.chat.completions.ChatCompletionContentPartImage;
+import com.openai.models.chat.completions.ChatCompletionContentPartInputAudio;
+import com.openai.models.chat.completions.ChatCompletionContentPartText;
 import com.openai.models.chat.completions.ChatCompletionMessage;
 import 
com.openai.models.chat.completions.ChatCompletionMessageFunctionToolCall;
 import com.openai.models.chat.completions.ChatCompletionMessageParam;
@@ -30,8 +34,17 @@ import 
com.openai.models.chat.completions.ChatCompletionMessageToolCall;
 import com.openai.models.chat.completions.ChatCompletionSystemMessageParam;
 import com.openai.models.chat.completions.ChatCompletionToolMessageParam;
 import com.openai.models.chat.completions.ChatCompletionUserMessageParam;
+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.MediaBlock;
 import org.apache.flink.agents.api.chat.messages.MessageRole;
+import org.apache.flink.agents.api.chat.messages.TextBlock;
+import 
org.apache.flink.agents.api.chat.messages.UnsupportedContentBlockException;
+import org.apache.flink.agents.api.chat.messages.UrlSource;
 import org.apache.flink.agents.api.resource.ResourceDescriptor;
 
 import java.math.BigDecimal;
@@ -41,6 +54,7 @@ import java.time.Duration;
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import java.util.stream.Collectors;
@@ -167,18 +181,25 @@ final class OpenAIChatCompletionsUtils {
                 .collect(Collectors.toList());
     }
 
-    /** Convert a single Flink Agents ChatMessage to an OpenAI 
ChatCompletionMessageParam. */
+    /**
+     * Convert a single Flink Agents ChatMessage to an OpenAI 
ChatCompletionMessageParam.
+     *
+     * <p>Only user messages can carry media; a media block in any other role, 
or one the Chat
+     * Completions API has no content part for, throws {@link 
UnsupportedContentBlockException}.
+     */
     public static ChatCompletionMessageParam 
convertToOpenAIMessage(ChatMessage message) {
         MessageRole role = message.getRole();
         String content = Optional.ofNullable(message.getText()).orElse("");
+        if (role != MessageRole.USER) {
+            requireTextOnly(message);
+        }
 
         switch (role) {
             case SYSTEM:
                 return ChatCompletionMessageParam.ofSystem(
                         
ChatCompletionSystemMessageParam.builder().content(content).build());
             case USER:
-                return ChatCompletionMessageParam.ofUser(
-                        
ChatCompletionUserMessageParam.builder().content(content).build());
+                return 
ChatCompletionMessageParam.ofUser(convertUserMessage(message, content));
             case ASSISTANT:
                 ChatCompletionAssistantMessageParam.Builder assistantBuilder =
                         ChatCompletionAssistantMessageParam.builder();
@@ -209,6 +230,125 @@ final class OpenAIChatCompletionsUtils {
         }
     }
 
+    /**
+     * A text-only user message keeps the plain string content, as before; a 
message with media is
+     * sent as content parts in block order.
+     */
+    private static ChatCompletionUserMessageParam convertUserMessage(
+            ChatMessage message, String text) {
+        List<ContentBlock> blocks = message.getBlocks();
+        if (blocks.stream().noneMatch(block -> block instanceof MediaBlock)) {
+            return 
ChatCompletionUserMessageParam.builder().content(text).build();
+        }
+        List<ChatCompletionContentPart> parts = new ArrayList<>(blocks.size());
+        for (ContentBlock block : blocks) {
+            parts.add(toContentPart(block));
+        }
+        return 
ChatCompletionUserMessageParam.builder().contentOfArrayOfContentParts(parts).build();
+    }
+
+    private static ChatCompletionContentPart toContentPart(ContentBlock block) 
{
+        if (block instanceof TextBlock) {
+            return ChatCompletionContentPart.ofText(
+                    ChatCompletionContentPartText.builder()
+                            .text(((TextBlock) block).getText())
+                            .build());
+        }
+        if (block instanceof ImageBlock) {
+            ImageBlock image = (ImageBlock) block;
+            String url =
+                    image.getSource() instanceof UrlSource
+                            ? ((UrlSource) image.getSource()).getUrl()
+                            : dataUri(image);
+            return ChatCompletionContentPart.ofImageUrl(
+                    ChatCompletionContentPartImage.builder()
+                            .imageUrl(
+                                    
ChatCompletionContentPartImage.ImageUrl.builder()
+                                            .url(url)
+                                            .build())
+                            .build());
+        }
+        if (block instanceof AudioBlock) {
+            AudioBlock audio = (AudioBlock) block;
+            if (!(audio.getSource() instanceof Base64Source)) {
+                throw unsupported(block, "audio input takes base64 data, not a 
URL");
+            }
+            ChatCompletionContentPartInputAudio.InputAudio.Format format =
+                    audioFormat(audio.getMediaType());
+            if (format == null) {
+                throw unsupported(block, "audio input takes WAV or MP3 only");
+            }
+            return ChatCompletionContentPart.ofInputAudio(
+                    ChatCompletionContentPartInputAudio.builder()
+                            .inputAudio(
+                                    
ChatCompletionContentPartInputAudio.InputAudio.builder()
+                                            .data(((Base64Source) 
audio.getSource()).getData())
+                                            .format(format)
+                                            .build())
+                            .build());
+        }
+        if (block instanceof DocumentBlock) {
+            DocumentBlock document = (DocumentBlock) block;
+            if (!(document.getSource() instanceof Base64Source)) {
+                throw unsupported(block, "file input takes base64 data, not a 
URL");
+            }
+            // OpenAI accepts PDF documents only; other types are left for the 
server to reject.
+            return ChatCompletionContentPart.ofFile(
+                    ChatCompletionContentPart.File.builder()
+                            .file(
+                                    
ChatCompletionContentPart.File.FileObject.builder()
+                                            .fileData(dataUri(document))
+                                            .filename(
+                                                    document.getName() != null
+                                                            ? 
document.getName()
+                                                            : "document")
+                                            .build())
+                            .build());
+        }
+        throw unsupported(block, "there is no content part for it");
+    }
+
+    private static void requireTextOnly(ChatMessage message) {
+        for (ContentBlock block : message.getBlocks()) {
+            if (block instanceof MediaBlock) {
+                throw unsupported(
+                        block,
+                        "only user messages can carry media, not "
+                                + message.getRole().getValue()
+                                + " messages");
+            }
+        }
+    }
+
+    /** Maps an audio media type onto the formats Chat Completions accepts, or 
null. */
+    private static ChatCompletionContentPartInputAudio.InputAudio.Format 
audioFormat(
+            String mediaType) {
+        String essence = mediaType.split(";", 
2)[0].trim().toLowerCase(Locale.ROOT);
+        switch (essence) {
+            case "audio/wav":
+            case "audio/wave":
+            case "audio/x-wav":
+            case "audio/vnd.wave":
+                return 
ChatCompletionContentPartInputAudio.InputAudio.Format.WAV;
+            case "audio/mpeg":
+            case "audio/mp3":
+                return 
ChatCompletionContentPartInputAudio.InputAudio.Format.MP3;
+            default:
+                return null;
+        }
+    }
+
+    private static String dataUri(MediaBlock block) {
+        return "data:"
+                + block.getMediaType()
+                + ";base64,"
+                + ((Base64Source) block.getSource()).getData();
+    }
+
+    private static UnsupportedContentBlockException unsupported(ContentBlock 
block, String reason) {
+        return UnsupportedContentBlockException.forBlock("OpenAI Chat 
Completions", block, reason);
+    }
+
     /**
      * Convert an OpenAI {@link ChatCompletionMessage} to a Flink Agents 
{@link ChatMessage}. {@code
      * message.refusal()} is written as {@code extraArgs["refusal"]} on the 
returned ChatMessage
diff --git 
a/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIChatCompletionsMultimodalLiveTest.java
 
b/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIChatCompletionsMultimodalLiveTest.java
new file mode 100644
index 00000000..f40c0d35
--- /dev/null
+++ 
b/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIChatCompletionsMultimodalLiveTest.java
@@ -0,0 +1,197 @@
+/*
+ * 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.openai;
+
+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.resource.ResourceContext;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import javax.imageio.ImageIO;
+
+import java.awt.Color;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static java.nio.charset.StandardCharsets.US_ASCII;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+/**
+ * Sends real multimodal requests through the OpenAI Chat Completions 
connection, to check that the
+ * provider accepts the content parts {@link 
OpenAIChatCompletionsMultimodalTest} pins.
+ *
+ * <p>Skipped unless {@code OPENAI_API_KEY} is set. {@code 
OPENAI_API_BASE_URL}, {@code
+ * OPENAI_MULTIMODAL_MODEL} (default {@code gpt-4o-mini}) and {@code 
OPENAI_AUDIO_MODEL} (default
+ * {@code gpt-4o-audio-preview}) override the endpoint and models.
+ */
+class OpenAIChatCompletionsMultimodalLiveTest {
+
+    private static final String API_KEY = System.getenv("OPENAI_API_KEY");
+
+    @BeforeAll
+    static void requireCredentials() {
+        assumeTrue(API_KEY != null && !API_KEY.isBlank(), "OPENAI_API_KEY is 
not set");
+    }
+
+    @Test
+    @DisplayName("A base64 image is accepted")
+    void testImage() throws Exception {
+        assertThat(
+                        chat(
+                                multimodalModel(),
+                                TextBlock.of("What color is this image? Answer 
in one word."),
+                                ImageBlock.fromBase64("image/png", 
redSquarePng())))
+                .isNotBlank();
+    }
+
+    @Test
+    @DisplayName("WAV audio is accepted")
+    void testAudio() {
+        assertThat(
+                        chat(
+                                env("OPENAI_AUDIO_MODEL", 
"gpt-4o-audio-preview"),
+                                TextBlock.of("Describe this audio clip in one 
sentence."),
+                                AudioBlock.fromBase64("audio/wav", 
silentWav())))
+                .isNotBlank();
+    }
+
+    @Test
+    @DisplayName("A named PDF is accepted")
+    void testNamedPdf() {
+        assertThat(
+                        chat(
+                                multimodalModel(),
+                                TextBlock.of("What text does this PDF 
contain?"),
+                                new DocumentBlock(
+                                        "application/pdf",
+                                        new Base64Source(helloPdf()),
+                                        "hello.pdf",
+                                        null,
+                                        null)))
+                .isNotBlank();
+    }
+
+    @Test
+    @DisplayName("A PDF without a name is accepted under the default file 
name")
+    void testUnnamedPdf() {
+        assertThat(
+                        chat(
+                                multimodalModel(),
+                                TextBlock.of("What text does this PDF 
contain?"),
+                                DocumentBlock.fromBase64("application/pdf", 
helloPdf())))
+                .isNotBlank();
+    }
+
+    private static String chat(String model, ContentBlock... blocks) {
+        ResourceDescriptor.Builder descriptor =
+                
ResourceDescriptor.Builder.newBuilder(OpenAICompletionsConnection.class.getName())
+                        .addInitialArgument("api_key", API_KEY);
+        String apiBaseUrl = System.getenv("OPENAI_API_BASE_URL");
+        if (apiBaseUrl != null && !apiBaseUrl.isBlank()) {
+            descriptor.addInitialArgument("api_base_url", apiBaseUrl);
+        }
+        OpenAICompletionsConnection connection =
+                new OpenAICompletionsConnection(
+                        descriptor.build(), 
ResourceContext.fromGetResource((name, type) -> null));
+        Map<String, Object> params = new HashMap<>();
+        params.put("model", model);
+        return connection
+                .chat(List.of(ChatMessage.user(List.of(blocks))), List.of(), 
params)
+                .getText();
+    }
+
+    private static String multimodalModel() {
+        return env("OPENAI_MULTIMODAL_MODEL", "gpt-4o-mini");
+    }
+
+    private static String env(String name, String defaultValue) {
+        String value = System.getenv(name);
+        return value == null || value.isBlank() ? defaultValue : value;
+    }
+
+    private static String redSquarePng() throws Exception {
+        BufferedImage image = new BufferedImage(16, 16, 
BufferedImage.TYPE_INT_RGB);
+        for (int x = 0; x < 16; x++) {
+            for (int y = 0; y < 16; y++) {
+                image.setRGB(x, y, Color.RED.getRGB());
+            }
+        }
+        ByteArrayOutputStream png = new ByteArrayOutputStream();
+        ImageIO.write(image, "png", png);
+        return Base64.getEncoder().encodeToString(png.toByteArray());
+    }
+
+    /** Half a second of 16 kHz mono 16-bit PCM silence. */
+    static String silentWav() {
+        int sampleRate = 16_000;
+        int dataSize = sampleRate / 2 * 2;
+        ByteBuffer wav = ByteBuffer.allocate(44 + 
dataSize).order(ByteOrder.LITTLE_ENDIAN);
+        wav.put("RIFF".getBytes(US_ASCII)).putInt(36 + 
dataSize).put("WAVE".getBytes(US_ASCII));
+        wav.put("fmt ".getBytes(US_ASCII)).putInt(16).putShort((short) 
1).putShort((short) 1);
+        wav.putInt(sampleRate).putInt(sampleRate * 2).putShort((short) 
2).putShort((short) 16);
+        wav.put("data".getBytes(US_ASCII)).putInt(dataSize);
+        return Base64.getEncoder().encodeToString(wav.array());
+    }
+
+    /** A one-page PDF reading "Hello PDF", with a valid cross-reference 
table. */
+    static String helloPdf() {
+        String stream = "BT /F1 24 Tf 20 40 Td (Hello PDF) Tj ET";
+        String[] objects = {
+            "<< /Type /Catalog /Pages 2 0 R >>",
+            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
+            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 100] /Contents 4 
0 R"
+                    + " /Resources << /Font << /F1 5 0 R >> >> >>",
+            "<< /Length " + stream.length() + " >>\nstream\n" + stream + 
"\nendstream",
+            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"
+        };
+        StringBuilder pdf = new StringBuilder("%PDF-1.4\n");
+        List<Integer> offsets = new ArrayList<>();
+        for (int i = 0; i < objects.length; i++) {
+            offsets.add(pdf.length());
+            pdf.append(i + 1).append(" 0 
obj\n").append(objects[i]).append("\nendobj\n");
+        }
+        int xref = pdf.length();
+        pdf.append("xref\n0 ").append(objects.length + 1).append("\n0000000000 
65535 f \n");
+        for (int offset : offsets) {
+            pdf.append(String.format("%010d 00000 n \n", offset));
+        }
+        pdf.append("trailer\n<< /Size ")
+                .append(objects.length + 1)
+                .append(" /Root 1 0 R >>\nstartxref\n")
+                .append(xref)
+                .append("\n%%EOF\n");
+        return 
Base64.getEncoder().encodeToString(pdf.toString().getBytes(US_ASCII));
+    }
+}
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
new file mode 100644
index 00000000..be6266a0
--- /dev/null
+++ 
b/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIChatCompletionsMultimodalTest.java
@@ -0,0 +1,161 @@
+/*
+ * 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.openai;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.openai.core.ObjectMappers;
+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.MessageRole;
+import org.apache.flink.agents.api.chat.messages.TextBlock;
+import 
org.apache.flink.agents.api.chat.messages.UnsupportedContentBlockException;
+import org.apache.flink.agents.api.chat.messages.VideoBlock;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests how content blocks become Chat Completions content parts. Assertions 
run on the serialized
+ * request message, i.e. the wire shape OpenAI, Azure OpenAI and vLLM receive.
+ */
+class OpenAIChatCompletionsMultimodalTest {
+
+    private static final String IMAGE_URL = 
"https://example.com/cat.png?sig=secret";;
+    private static final String BASE64 = "aGVsbG8=";
+
+    private static JsonNode wire(ChatMessage message) {
+        return ObjectMappers.jsonMapper()
+                
.valueToTree(OpenAIChatCompletionsUtils.convertToOpenAIMessage(message));
+    }
+
+    @Test
+    @DisplayName("A text-only user message keeps plain string content")
+    void testTextOnlyUserMessageKeepsStringContent() {
+        JsonNode message =
+                wire(ChatMessage.user(List.of(TextBlock.of("Describe "), 
TextBlock.of("this"))));
+
+        assertThat(message.get("content").isTextual()).isTrue();
+        assertThat(message.get("content").asText()).isEqualTo("Describe this");
+    }
+
+    @Test
+    @DisplayName("User media becomes content parts in block order")
+    void testUserMediaBecomesOrderedContentParts() {
+        JsonNode parts =
+                wire(ChatMessage.user(
+                                List.of(
+                                        TextBlock.of("Compare"),
+                                        ImageBlock.fromUrl("image/png", 
IMAGE_URL),
+                                        ImageBlock.fromBase64("image/jpeg", 
BASE64))))
+                        .get("content");
+
+        assertThat(parts).hasSize(3);
+        assertThat(parts.get(0).get("type").asText()).isEqualTo("text");
+        assertThat(parts.get(0).get("text").asText()).isEqualTo("Compare");
+        assertThat(parts.get(1).get("type").asText()).isEqualTo("image_url");
+        
assertThat(parts.get(1).at("/image_url/url").asText()).isEqualTo(IMAGE_URL);
+        assertThat(parts.get(2).at("/image_url/url").asText())
+                .isEqualTo("data:image/jpeg;base64," + BASE64);
+    }
+
+    @Test
+    @DisplayName("WAV and MP3 audio becomes input_audio with the matching 
format")
+    void testAudioBecomesInputAudio() {
+        JsonNode parts =
+                wire(ChatMessage.user(
+                                List.of(
+                                        AudioBlock.fromBase64("audio/wav", 
BASE64),
+                                        AudioBlock.fromBase64("audio/mpeg", 
BASE64))))
+                        .get("content");
+
+        assertThat(parts.get(0).get("type").asText()).isEqualTo("input_audio");
+        
assertThat(parts.get(0).at("/input_audio/data").asText()).isEqualTo(BASE64);
+        
assertThat(parts.get(0).at("/input_audio/format").asText()).isEqualTo("wav");
+        
assertThat(parts.get(1).at("/input_audio/format").asText()).isEqualTo("mp3");
+    }
+
+    @Test
+    @DisplayName("A base64 document becomes a file part with a data URI and a 
file name")
+    void testDocumentBecomesFilePart() {
+        JsonNode parts =
+                wire(ChatMessage.user(
+                                List.of(
+                                        new DocumentBlock(
+                                                "application/pdf",
+                                                new Base64Source(BASE64),
+                                                "report.pdf",
+                                                null,
+                                                null),
+                                        
DocumentBlock.fromBase64("application/pdf", BASE64))))
+                        .get("content");
+
+        assertThat(parts.get(0).get("type").asText()).isEqualTo("file");
+        assertThat(parts.get(0).at("/file/file_data").asText())
+                .isEqualTo("data:application/pdf;base64," + BASE64);
+        
assertThat(parts.get(0).at("/file/filename").asText()).isEqualTo("report.pdf");
+        
assertThat(parts.get(1).at("/file/filename").asText()).isEqualTo("document");
+    }
+
+    @Test
+    @DisplayName("Blocks without a Chat Completions content part fail without 
leaking the source")
+    void testUnsupportedUserBlocksFailExplicitly() {
+        List<ContentBlock> unsupported =
+                List.of(
+                        VideoBlock.fromUrl("video/mp4", IMAGE_URL),
+                        AudioBlock.fromUrl("audio/wav", IMAGE_URL),
+                        AudioBlock.fromBase64("audio/ogg", BASE64),
+                        DocumentBlock.fromUrl("application/pdf", IMAGE_URL));
+        for (ContentBlock block : unsupported) {
+            assertThatThrownBy(
+                            () ->
+                                    
OpenAIChatCompletionsUtils.convertToOpenAIMessage(
+                                            
ChatMessage.user(List.of(TextBlock.of("hi"), block))))
+                    .isInstanceOf(UnsupportedContentBlockException.class)
+                    .hasMessageContaining(block.getType() + " block")
+                    .hasMessageNotContaining("secret")
+                    .hasMessageNotContaining(BASE64);
+        }
+    }
+
+    @ParameterizedTest
+    @EnumSource(
+            value = MessageRole.class,
+            names = {"SYSTEM", "ASSISTANT", "TOOL"})
+    @DisplayName("Media outside user messages fails explicitly")
+    void testMediaOutsideUserMessagesFails(MessageRole role) {
+        ChatMessage message =
+                new ChatMessage(
+                        role,
+                        List.of(TextBlock.of("see"), 
ImageBlock.fromUrl("image/png", IMAGE_URL)));
+
+        assertThatThrownBy(() -> 
OpenAIChatCompletionsUtils.convertToOpenAIMessage(message))
+                .isInstanceOf(UnsupportedContentBlockException.class)
+                .hasMessageContaining("only user messages can carry media");
+    }
+}
diff --git a/python/flink_agents/api/chat_message.py 
b/python/flink_agents/api/chat_message.py
index e6f7c7fc..fbc95c1a 100644
--- a/python/flink_agents/api/chat_message.py
+++ b/python/flink_agents/api/chat_message.py
@@ -177,6 +177,31 @@ ContentBlock = Annotated[
 ]
 
 
+class UnsupportedContentBlockError(ValueError):
+    """A chat model integration cannot send a content block to its provider.
+
+    Raised for a media type the provider does not accept, or a media block in
+    a message role that only takes text, rather than silently dropping or
+    converting the block. Messages name the block type, media type and source
+    type only, never the media payload or URL.
+    """
+
+    @classmethod
+    def for_block(
+        cls, provider: str, block: ContentBlock, reason: str
+    ) -> "UnsupportedContentBlockError":
+        """Create the error for a block the provider cannot send.
+
+        The message reads "{provider} cannot send a(n) {type} block ({media 
type},
+        {source type} source): {reason}."
+        """
+        article = "an" if block.type[0] in "aeiou" else "a"
+        description = f"{article} {block.type} block"
+        if isinstance(block, MediaBlock):
+            description += f" ({block.media_type}, {block.source.type} source)"
+        return cls(f"{provider} cannot send {description}: {reason}.")
+
+
 def _blocks_of(text: str) -> List[ContentBlock]:
     """An empty text becomes an empty block list rather than an empty text 
block."""
     return [TextBlock(text=text)] if text else []
diff --git 
a/python/flink_agents/integrations/chat_models/openai/openai_utils.py 
b/python/flink_agents/integrations/chat_models/openai/openai_utils.py
index 8a08934f..c02e3f35 100644
--- a/python/flink_agents/integrations/chat_models/openai/openai_utils.py
+++ b/python/flink_agents/integrations/chat_models/openai/openai_utils.py
@@ -22,6 +22,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Sequence, 
Tuple
 
 import openai
 from openai.types.chat import (
+    ChatCompletionContentPartParam,
     ChatCompletionMessage,
     ChatCompletionMessageParam,
     ChatCompletionMessageToolCallParam,
@@ -36,7 +37,19 @@ if TYPE_CHECKING:
     )
     from openai.types.chat.chat_completion_message_tool_call_param import 
Function
 
-from flink_agents.api.chat_message import ChatMessage, MessageRole
+from flink_agents.api.chat_message import (
+    AudioBlock,
+    Base64Source,
+    ChatMessage,
+    ContentBlock,
+    DocumentBlock,
+    ImageBlock,
+    MediaBlock,
+    MessageRole,
+    TextBlock,
+    UnsupportedContentBlockError,
+    UrlSource,
+)
 
 DEFAULT_OPENAI_API_BASE_URL = "https://api.openai.com/v1";
 
@@ -121,6 +134,79 @@ def _convert_to_openai_tool_call(tool_call: dict) -> 
ChatCompletionMessageToolCa
     return openai_tool_call
 
 
+_AUDIO_FORMATS = {
+    "audio/wav": "wav",
+    "audio/wave": "wav",
+    "audio/x-wav": "wav",
+    "audio/vnd.wave": "wav",
+    "audio/mpeg": "mp3",
+    "audio/mp3": "mp3",
+}
+
+
+def _unsupported(block: ContentBlock, reason: str) -> 
UnsupportedContentBlockError:
+    return UnsupportedContentBlockError.for_block(
+        "OpenAI Chat Completions", block, reason
+    )
+
+
+def _data_uri(block: MediaBlock) -> str:
+    return f"data:{block.media_type};base64,{block.source.data}"
+
+
+def _to_content_part(block: ContentBlock) -> ChatCompletionContentPartParam:
+    if isinstance(block, TextBlock):
+        return {"type": "text", "text": block.text}
+    if isinstance(block, ImageBlock):
+        url = (
+            block.source.url
+            if isinstance(block.source, UrlSource)
+            else _data_uri(block)
+        )
+        return {"type": "image_url", "image_url": {"url": url}}
+    if isinstance(block, AudioBlock):
+        if not isinstance(block.source, Base64Source):
+            raise _unsupported(block, "audio input takes base64 data, not a 
URL")
+        audio_format = _AUDIO_FORMATS.get(
+            block.media_type.split(";", 1)[0].strip().lower()
+        )
+        if audio_format is None:
+            raise _unsupported(block, "audio input takes WAV or MP3 only")
+        return {
+            "type": "input_audio",
+            "input_audio": {"data": block.source.data, "format": audio_format},
+        }
+    if isinstance(block, DocumentBlock):
+        if not isinstance(block.source, Base64Source):
+            raise _unsupported(block, "file input takes base64 data, not a 
URL")
+        # OpenAI accepts PDF documents only; other types are left for the
+        # server to reject.
+        return {
+            "type": "file",
+            "file": {
+                "file_data": _data_uri(block),
+                "filename": block.name if block.name is not None else 
"document",
+            },
+        }
+    raise _unsupported(block, "there is no content part for it")
+
+
+def _user_content(message: ChatMessage) -> str | 
List[ChatCompletionContentPartParam]:
+    """Keep plain string content for text-only messages; media switches to 
parts."""
+    if not any(isinstance(block, MediaBlock) for block in message.blocks):
+        return message.text
+    return [_to_content_part(block) for block in message.blocks]
+
+
+def _require_text_only(message: ChatMessage) -> None:
+    for block in message.blocks:
+        if isinstance(block, MediaBlock):
+            reason = (
+                f"only user messages can carry media, not {message.role.value} 
messages"
+            )
+            raise _unsupported(block, reason)
+
+
 def convert_to_openai_messages(
     messages: Sequence[ChatMessage],
 ) -> List[ChatCompletionMessageParam]:
@@ -141,8 +227,14 @@ def convert_to_openai_message(message: ChatMessage) -> 
ChatCompletionMessagePara
     extra_args are not forwarded as message fields, except that a tool
     message takes its tool_call_id from extra_args["external_id"], and an
     assistant message carries extra_args["refusal"] when that value is a str.
+
+    Only user messages can carry media. A media block in any other role, or
+    one the Chat Completions API has no content part for, raises
+    UnsupportedContentBlockError.
     """
     role = message.role
+    if role != MessageRole.USER:
+        _require_text_only(message)
 
     # Handle SYSTEM role messages
     if role == MessageRole.SYSTEM:
@@ -156,7 +248,7 @@ def convert_to_openai_message(message: ChatMessage) -> 
ChatCompletionMessagePara
     elif role == MessageRole.USER:
         user_message: ChatCompletionUserMessageParam = {
             "role": "user",
-            "content": message.text,
+            "content": _user_content(message),
         }
         return user_message
     # Handle ASSISTANT role messages
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
new file mode 100644
index 00000000..a2895cec
--- /dev/null
+++ 
b/python/flink_agents/integrations/chat_models/openai/tests/test_openai_multimodal.py
@@ -0,0 +1,143 @@
+################################################################################
+#  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 become Chat Completions content parts.
+
+The converter is shared by the OpenAI, Azure OpenAI and vLLM connections, so
+these cases pin the wire shape all three send. They mirror the Java
+OpenAIChatCompletionsMultimodalTest.
+"""
+
+import pytest
+
+from flink_agents.api.chat_message import (
+    AudioBlock,
+    Base64Source,
+    ChatMessage,
+    ContentBlock,
+    DocumentBlock,
+    ImageBlock,
+    MessageRole,
+    TextBlock,
+    UnsupportedContentBlockError,
+    VideoBlock,
+)
+from flink_agents.integrations.chat_models.openai.openai_utils import (
+    convert_to_openai_message,
+)
+
+IMAGE_URL = "https://example.com/cat.png?sig=secret";
+BASE64 = "aGVsbG8="
+
+
+def test_text_only_user_message_keeps_string_content() -> None:
+    """A user message without media keeps the plain string content."""
+    message = ChatMessage.user([TextBlock(text="Describe "), 
TextBlock(text="this")])
+
+    assert convert_to_openai_message(message)["content"] == "Describe this"
+
+
+def test_user_media_becomes_ordered_content_parts() -> None:
+    """Media switches the user content to parts, in block order."""
+    message = ChatMessage.user(
+        [
+            TextBlock(text="Compare"),
+            ImageBlock.from_url("image/png", IMAGE_URL),
+            ImageBlock.from_base64("image/jpeg", BASE64),
+        ]
+    )
+
+    assert convert_to_openai_message(message)["content"] == [
+        {"type": "text", "text": "Compare"},
+        {"type": "image_url", "image_url": {"url": IMAGE_URL}},
+        {"type": "image_url", "image_url": {"url": 
f"data:image/jpeg;base64,{BASE64}"}},
+    ]
+
+
+def test_audio_becomes_input_audio() -> None:
+    """WAV and MP3 audio map onto the two formats input_audio accepts."""
+    message = ChatMessage.user(
+        [
+            AudioBlock.from_base64("audio/wav", BASE64),
+            AudioBlock.from_base64("audio/mpeg", BASE64),
+        ]
+    )
+
+    assert convert_to_openai_message(message)["content"] == [
+        {"type": "input_audio", "input_audio": {"data": BASE64, "format": 
"wav"}},
+        {"type": "input_audio", "input_audio": {"data": BASE64, "format": 
"mp3"}},
+    ]
+
+
+def test_document_becomes_file_part() -> None:
+    """A base64 document is sent as file data with a file name."""
+    message = ChatMessage.user(
+        [
+            DocumentBlock(
+                media_type="application/pdf",
+                source=Base64Source(data=BASE64),
+                name="report.pdf",
+            ),
+            DocumentBlock.from_base64("application/pdf", BASE64),
+        ]
+    )
+
+    parts = convert_to_openai_message(message)["content"]
+    assert parts[0] == {
+        "type": "file",
+        "file": {
+            "file_data": f"data:application/pdf;base64,{BASE64}",
+            "filename": "report.pdf",
+        },
+    }
+    assert parts[1]["file"]["filename"] == "document"
+
+
[email protected](
+    "block",
+    [
+        VideoBlock.from_url("video/mp4", IMAGE_URL),
+        AudioBlock.from_url("audio/wav", IMAGE_URL),
+        AudioBlock.from_base64("audio/ogg", BASE64),
+        DocumentBlock.from_url("application/pdf", IMAGE_URL),
+    ],
+    ids=["video", "audio-url", "audio-ogg", "document-url"],
+)
+def test_unsupported_user_blocks_fail_explicitly(block: ContentBlock) -> None:
+    """Blocks without a content part fail, naming the block but not its 
source."""
+    message = ChatMessage.user([TextBlock(text="hi"), block])
+
+    with pytest.raises(UnsupportedContentBlockError) as error:
+        convert_to_openai_message(message)
+
+    assert f"{block.type} block" in str(error.value)
+    assert "secret" not in str(error.value)
+    assert BASE64 not in str(error.value)
+
+
[email protected](
+    "role", [MessageRole.SYSTEM, MessageRole.ASSISTANT, MessageRole.TOOL]
+)
+def test_media_outside_user_messages_fails(role: MessageRole) -> None:
+    """Only user messages can carry media."""
+    message = ChatMessage(
+        role=role,
+        blocks=[TextBlock(text="see"), ImageBlock.from_url("image/png", 
IMAGE_URL)],
+    )
+
+    with pytest.raises(UnsupportedContentBlockError, match="only user 
messages"):
+        convert_to_openai_message(message)
diff --git 
a/python/flink_agents/integrations/chat_models/openai/tests/test_openai_multimodal_live.py
 
b/python/flink_agents/integrations/chat_models/openai/tests/test_openai_multimodal_live.py
new file mode 100644
index 00000000..73ed2f6a
--- /dev/null
+++ 
b/python/flink_agents/integrations/chat_models/openai/tests/test_openai_multimodal_live.py
@@ -0,0 +1,161 @@
+################################################################################
+#  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.
+#################################################################################
+"""Real multimodal requests through the OpenAI Chat Completions connection.
+
+Checks that the provider accepts the content parts test_openai_multimodal.py
+pins. Skipped unless TEST_API_KEY is set; TEST_API_BASE_URL,
+TEST_MULTIMODAL_MODEL (default gpt-4o-mini) and TEST_AUDIO_MODEL (default
+gpt-4o-audio-preview) override the endpoint and models. Mirrors the Java
+OpenAIChatCompletionsMultimodalLiveTest.
+"""
+
+import base64
+import io
+import os
+import struct
+import wave
+import zlib
+
+import pytest
+
+from flink_agents.api.chat_message import (
+    AudioBlock,
+    Base64Source,
+    ChatMessage,
+    ContentBlock,
+    DocumentBlock,
+    ImageBlock,
+    TextBlock,
+)
+from flink_agents.integrations.chat_models.openai.openai_chat_model import (
+    OpenAIChatModelConnection,
+)
+
+pytestmark = [
+    pytest.mark.integration,
+    pytest.mark.skipif(
+        not os.environ.get("TEST_API_KEY"), reason="TEST_API_KEY is not set"
+    ),
+]
+
+MULTIMODAL_MODEL = os.environ.get("TEST_MULTIMODAL_MODEL") or "gpt-4o-mini"
+AUDIO_MODEL = os.environ.get("TEST_AUDIO_MODEL") or "gpt-4o-audio-preview"
+
+
+def _chat(model: str, *blocks: ContentBlock) -> str:
+    connection = OpenAIChatModelConnection(
+        api_key=os.environ["TEST_API_KEY"],
+        api_base_url=os.environ.get("TEST_API_BASE_URL"),
+    )
+    return connection.chat([ChatMessage.user(list(blocks))], model=model).text
+
+
+def _red_square_png() -> str:
+    """A 16x16 red PNG."""
+    rows = b"".join(b"\x00" + b"\xff\x00\x00" * 16 for _ in range(16))
+
+    def chunk(kind: bytes, data: bytes) -> bytes:
+        body = kind + data
+        return struct.pack(">I", len(data)) + body + struct.pack(">I", 
zlib.crc32(body))
+
+    png = (
+        b"\x89PNG\r\n\x1a\n"
+        + chunk(b"IHDR", struct.pack(">IIBBBBB", 16, 16, 8, 2, 0, 0, 0))
+        + chunk(b"IDAT", zlib.compress(rows))
+        + chunk(b"IEND", b"")
+    )
+    return base64.b64encode(png).decode()
+
+
+def _silent_wav() -> str:
+    """Half a second of 16 kHz mono 16-bit PCM silence."""
+    buffer = io.BytesIO()
+    with wave.open(buffer, "wb") as wav:
+        wav.setnchannels(1)
+        wav.setsampwidth(2)
+        wav.setframerate(16_000)
+        wav.writeframes(b"\x00\x00" * 8_000)
+    return base64.b64encode(buffer.getvalue()).decode()
+
+
+def _hello_pdf() -> str:
+    """A one-page PDF reading "Hello PDF", with a valid cross-reference 
table."""
+    stream = "BT /F1 24 Tf 20 40 Td (Hello PDF) Tj ET"
+    objects = [
+        "<< /Type /Catalog /Pages 2 0 R >>",
+        "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
+        "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 100] /Contents 4 0 R"
+        " /Resources << /Font << /F1 5 0 R >> >> >>",
+        f"<< /Length {len(stream)} >>\nstream\n{stream}\nendstream",
+        "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
+    ]
+    pdf = "%PDF-1.4\n"
+    offsets = []
+    for number, body in enumerate(objects, start=1):
+        offsets.append(len(pdf))
+        pdf += f"{number} 0 obj\n{body}\nendobj\n"
+    xref = len(pdf)
+    pdf += f"xref\n0 {len(objects) + 1}\n0000000000 65535 f \n"
+    pdf += "".join(f"{offset:010d} 00000 n \n" for offset in offsets)
+    pdf += f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n"
+    pdf += f"startxref\n{xref}\n%%EOF\n"
+    return base64.b64encode(pdf.encode("ascii")).decode()
+
+
+def test_image() -> None:
+    """A base64 image is accepted."""
+    answer = _chat(
+        MULTIMODAL_MODEL,
+        TextBlock(text="What color is this image? Answer in one word."),
+        ImageBlock.from_base64("image/png", _red_square_png()),
+    )
+    assert answer.strip()
+
+
+def test_audio() -> None:
+    """WAV audio is accepted."""
+    answer = _chat(
+        AUDIO_MODEL,
+        TextBlock(text="Describe this audio clip in one sentence."),
+        AudioBlock.from_base64("audio/wav", _silent_wav()),
+    )
+    assert answer.strip()
+
+
+def test_named_pdf() -> None:
+    """A named PDF is accepted."""
+    answer = _chat(
+        MULTIMODAL_MODEL,
+        TextBlock(text="What text does this PDF contain?"),
+        DocumentBlock(
+            media_type="application/pdf",
+            source=Base64Source(data=_hello_pdf()),
+            name="hello.pdf",
+        ),
+    )
+    assert answer.strip()
+
+
+def test_unnamed_pdf() -> None:
+    """A PDF without a name is accepted under the default file name."""
+    answer = _chat(
+        MULTIMODAL_MODEL,
+        TextBlock(text="What text does this PDF contain?"),
+        DocumentBlock.from_base64("application/pdf", _hello_pdf()),
+    )
+    assert answer.strip()

Reply via email to