Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import io.agentscope.core.message.AudioBlock;
import io.agentscope.core.message.Base64Source;
import io.agentscope.core.message.ContentBlock;
import io.agentscope.core.message.DataBlock;
import io.agentscope.core.message.HintBlock;
import io.agentscope.core.message.ImageBlock;
import io.agentscope.core.message.MessageMetadataKeys;
Expand Down Expand Up @@ -134,7 +135,8 @@ protected boolean hasMediaContent(Msg msg) {
for (ContentBlock block : msg.getContent()) {
if (block instanceof ImageBlock
|| block instanceof AudioBlock
|| block instanceof VideoBlock) {
|| block instanceof VideoBlock
|| block instanceof DataBlock) {
return true;
}
}
Expand Down Expand Up @@ -218,6 +220,9 @@ protected String convertToolResultToString(List<ContentBlock> output) {
} else if (block instanceof VideoBlock vb) {
String reference = convertMediaBlockToTextReference(vb, "video");
textualOutput.add(reference);
} else if (block instanceof DataBlock db) {
String reference = convertMediaBlockToTextReference(db, "data");
textualOutput.add(reference);
}
// Other block types (e.g., ThinkingBlock) are ignored
}
Expand Down Expand Up @@ -272,6 +277,8 @@ private Source getSourceFromBlock(ContentBlock block) {
return ab.getSource();
} else if (block instanceof VideoBlock vb) {
return vb.getSource();
} else if (block instanceof DataBlock db) {
return db.getSource();
}
throw new IllegalArgumentException("Unsupported block type: " + block.getClass());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package io.agentscope.core.message;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Objects;

Expand All @@ -35,20 +36,40 @@
*
* <p>Using URL sources is more efficient for large media files and allows
* the system to stream content rather than loading everything into memory.
*
* <p>When the URL has no file extension (e.g. CDN signed URLs), set {@code mimeType}
* explicitly so converters can route the content to the correct media slot without
* relying on extension-based inference.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class URLSource extends Source {

private final String url;

@JsonProperty("mime_type")
private final String mimeType;

/**
* Creates a new URL source for JSON deserialization.
*
* @param url The URL pointing to the media content
* @param mimeType Optional MIME type hint (e.g. "image/jpeg"); may be null
* @throws NullPointerException if url is null
*/
@JsonCreator
public URLSource(@JsonProperty("url") String url) {
public URLSource(@JsonProperty("url") String url, @JsonProperty("mime_type") String mimeType) {
this.url = Objects.requireNonNull(url, "url cannot be null");
this.mimeType = mimeType;
}

/**
* Creates a new URL source without a MIME type hint.
*
* @param url The URL pointing to the media content
* @throws NullPointerException if url is null
*/
public URLSource(String url) {
this(url, null);
}

/**
Expand All @@ -60,6 +81,19 @@ public String getUrl() {
return url;
}

/**
* Gets the optional MIME type hint for this URL source.
*
* <p>When present, converters use this value instead of inferring the type
* from the URL's file extension. Useful for extension-less URLs such as
* CDN signed links or API-generated media endpoints.
*
* @return The MIME type (e.g. "image/jpeg"), or null if not set
*/
public String getMimeType() {
return mimeType;
}

/**
* Creates a new builder for constructing URLSource instances.
*
Expand All @@ -76,6 +110,8 @@ public static class Builder {

private String url;

private String mimeType;

/**
* Sets the URL for the media content.
*
Expand All @@ -88,13 +124,24 @@ public Builder url(String url) {
}

/**
* Builds a new URLSource with the configured URL.
* Sets an optional MIME type hint for extension-less URLs.
*
* @param mimeType The MIME type (e.g. "video/mp4")
* @return This builder for chaining
*/
public Builder mimeType(String mimeType) {
this.mimeType = mimeType;
return this;
}

/**
* Builds a new URLSource with the configured fields.
*
* @return A new URLSource instance
* @throws NullPointerException if url is null
*/
public URLSource build() {
return new URLSource(url);
return new URLSource(url, mimeType);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import com.anthropic.models.messages.UrlImageSource;
import io.agentscope.core.formatter.MediaUtils;
import io.agentscope.core.message.Base64Source;
import io.agentscope.core.message.DataBlock;
import io.agentscope.core.message.ImageBlock;
import io.agentscope.core.message.Source;
import io.agentscope.core.message.URLSource;
Expand Down Expand Up @@ -79,4 +80,83 @@ public ImageBlockParam convertImageBlock(ImageBlock imageBlock) throws Exception
throw new IllegalArgumentException("Unsupported source type: " + source.getClass());
}
}

/**
* Convert DataBlock to Anthropic ImageBlockParam by resolving MIME type and routing to image.
*
* <p>Anthropic currently supports image modality only via this SDK type. Audio and video
* DataBlocks will throw {@link IllegalArgumentException} since the Anthropic API does not
* expose a generic binary content block param yet.
*
* <p>MIME type resolution order:
* <ol>
* <li>{@code Base64Source.mediaType} — always explicit</li>
* <li>{@code URLSource.mimeType} — caller-supplied hint for extension-less URLs</li>
* <li>{@code MediaUtils.determineMediaType(url)} — extension-based inference</li>
* </ol>
*
* @param dataBlock The data block to convert
* @return ImageBlockParam for Anthropic API
* @throws Exception If conversion fails or MIME type resolves to a non-image category
*/
public ImageBlockParam convertDataBlock(DataBlock dataBlock) throws Exception {
Source source = dataBlock.getSource();
String mimeType = resolveMimeType(source);

if (!mimeType.startsWith("image/")) {
throw new IllegalArgumentException(
"Anthropic API only supports image DataBlocks; got MIME type: " + mimeType);
}

if (source instanceof URLSource urlSource) {
String url = urlSource.getUrl();
if (MediaUtils.isLocalFile(url)) {
String base64Data = MediaUtils.fileToBase64(url);
return ImageBlockParam.builder()
.source(
Base64ImageSource.builder()
.data(base64Data)
.mediaType(Base64ImageSource.MediaType.of(mimeType))
.build())
.build();
} else {
// mimeType already verified to be image/* above; skip extension check
// so that extension-less CDN URLs with an explicit mimeType hint work
return ImageBlockParam.builder()
.source(UrlImageSource.builder().url(url).build())
.build();
}
} else if (source instanceof Base64Source base64Source) {
return ImageBlockParam.builder()
.source(
Base64ImageSource.builder()
.data(base64Source.getData())
.mediaType(Base64ImageSource.MediaType.of(mimeType))
.build())
.build();
} else {
throw new IllegalArgumentException("Unsupported source type: " + source.getClass());
}
}

private String resolveMimeType(Source source) {
if (source instanceof Base64Source b64) {
return b64.getMediaType();
}
if (source instanceof URLSource urlSource) {
String hint = urlSource.getMimeType();
if (hint != null && !hint.isBlank()) {
return hint;
}
String inferred = MediaUtils.determineMediaType(urlSource.getUrl());
if (!"application/octet-stream".equals(inferred)) {
return inferred;
}
throw new IllegalArgumentException(
"Cannot determine MIME type for URL '"
+ urlSource.getUrl()
+ "'; set URLSource.mimeType explicitly");
}
throw new IllegalArgumentException("Unsupported source type: " + source.getClass());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import com.anthropic.models.messages.ToolResultBlockParam;
import com.anthropic.models.messages.ToolUseBlockParam;
import io.agentscope.core.message.ContentBlock;
import io.agentscope.core.message.DataBlock;
import io.agentscope.core.message.HintBlock;
import io.agentscope.core.message.ImageBlock;
import io.agentscope.core.message.Msg;
Expand Down Expand Up @@ -155,6 +156,21 @@ private MessageParam convertMessageContent(
+ "]")
.build()));
}
} else if (block instanceof DataBlock db) {
try {
ImageBlockParam imageParam = mediaConverter.convertDataBlock(db);
contentBlocks.add(ContentBlockParam.ofImage(imageParam));
} catch (Exception e) {
log.warn("Failed to process DataBlock: {}", e.getMessage());
contentBlocks.add(
ContentBlockParam.ofText(
TextBlockParam.builder()
.text(
"[Media - processing failed: "
+ e.getMessage()
+ "]")
.build()));
}
} else if (block instanceof ToolUseBlock tub) {
contentBlocks.add(
ContentBlockParam.ofToolUse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import com.anthropic.models.messages.ImageBlockParam;
import com.anthropic.models.messages.UrlImageSource;
import io.agentscope.core.message.Base64Source;
import io.agentscope.core.message.DataBlock;
import io.agentscope.core.message.ImageBlock;
import io.agentscope.core.message.Source;
import io.agentscope.core.message.URLSource;
Expand Down Expand Up @@ -173,4 +174,86 @@ void testConvertImageBlockWithGifMediaType() throws Exception {

// Custom source type for testing unsupported sources
private static class CustomSource extends Source {}

@Test
void testConvertDataBlockWithBase64Source() throws Exception {
Base64Source source =
Base64Source.builder()
.data("ZmFrZSBpbWFnZSBjb250ZW50")
.mediaType("image/png")
.build();
DataBlock block = DataBlock.builder().source(source).build();

ImageBlockParam result = converter.convertDataBlock(block);

assertNotNull(result);
assertTrue(result.source().isBase64());
Base64ImageSource base64Source = result.source().asBase64();
assertEquals("ZmFrZSBpbWFnZSBjb250ZW50", base64Source.data());
assertEquals("image/png", base64Source.mediaType().toString());
}

@Test
void testConvertDataBlockWithRemoteURLAndExtension() throws Exception {
String remoteUrl = "https://example.com/photo.jpg";
URLSource source = URLSource.builder().url(remoteUrl).build();
DataBlock block = DataBlock.builder().source(source).build();

ImageBlockParam result = converter.convertDataBlock(block);

assertNotNull(result);
assertTrue(result.source().isUrl());
assertEquals(remoteUrl, result.source().asUrl().url());
}

@Test
void testConvertDataBlockWithMimeTypeHintExtensionlessUrl() throws Exception {
// Extension-less CDN URL with explicit mimeType hint — the primary use case
String cdnUrl = "https://cdn.example.com/media/abc123";
URLSource source = URLSource.builder().url(cdnUrl).mimeType("image/png").build();
DataBlock block = DataBlock.builder().source(source).build();

ImageBlockParam result = converter.convertDataBlock(block);

assertNotNull(result);
assertTrue(result.source().isUrl());
assertEquals(cdnUrl, result.source().asUrl().url());
}

@Test
void testConvertDataBlockWithLocalFile() throws Exception {
URLSource source = URLSource.builder().url(tempImageFile.toString()).build();
DataBlock block = DataBlock.builder().source(source).build();

ImageBlockParam result = converter.convertDataBlock(block);

assertNotNull(result);
assertTrue(result.source().isBase64());
byte[] decoded = Base64.getDecoder().decode(result.source().asBase64().data());
assertEquals("fake image content", new String(decoded));
}

@Test
void testConvertDataBlockNonImageMimeTypeThrows() {
// Anthropic only supports image — audio/video DataBlocks must throw
Base64Source source =
Base64Source.builder()
.data("ZmFrZSBhdWRpbyBjb250ZW50")
.mediaType("audio/mp3")
.build();
DataBlock block = DataBlock.builder().source(source).build();

IllegalArgumentException ex =
assertThrows(
IllegalArgumentException.class, () -> converter.convertDataBlock(block));
assertTrue(ex.getMessage().contains("image"));
}

@Test
void testConvertDataBlockNoExtensionNoHintThrows() {
URLSource source = URLSource.builder().url("https://cdn.example.com/media/abc123").build();
DataBlock block = DataBlock.builder().source(source).build();

assertThrows(Exception.class, () -> converter.convertDataBlock(block));
}
}
Loading
Loading