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 @@ -105,6 +105,7 @@
import org.springframework.ai.chat.observation.DefaultChatModelObservationConvention;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.content.ContentPart;
import org.springframework.ai.content.Media;
import org.springframework.ai.model.tool.ToolCallingManager;
import org.springframework.ai.observation.conventions.AiProvider;
Expand Down Expand Up @@ -726,7 +727,7 @@ else if (requestOptions.getCacheOptions().isMultiBlockSystemCaching() && systemT
userCacheControl = cacheResolver.resolve(MessageType.USER, combinedText);
}

if (hasCitationDocs || hasMedia || userCacheControl != null) {
if (hasCitationDocs || hasMedia || userCacheControl != null || userMessage.hasInterleavedContent()) {
List<ContentBlockParam> contentBlocks = new ArrayList<>();

// Prepend citation document blocks to the first user message
Expand All @@ -736,19 +737,29 @@ else if (requestOptions.getCacheOptions().isMultiBlockSystemCaching() && systemT
}
}

String text = userMessage.getText();
if (text != null && !text.isEmpty()) {
TextBlockParam.Builder textBlockBuilder = TextBlockParam.builder().text(text);
if (userCacheControl != null) {
textBlockBuilder.cacheControl(userCacheControl);
cacheResolver.useCacheBlock();
// Walking the content parts preserves the caller's text/media
// ordering.
// For a message built in the flat form the parts are simply the text
// followed by the media, so this produces the same blocks as before.
List<ContentPart> contentParts = userMessage.getContentParts();
int cacheTextPartIndex = lastNonEmptyTextPartIndex(contentParts);
for (int p = 0; p < contentParts.size(); p++) {
ContentPart contentPart = contentParts.get(p);
if (contentPart instanceof ContentPart.TextPart textPart) {
if (textPart.text().isEmpty()) {
continue;
}
TextBlockParam.Builder textBlockBuilder = TextBlockParam.builder().text(textPart.text());
// A cache prefix has to cover the whole content, so it is
// attached to the final text block rather than the first.
if (userCacheControl != null && p == cacheTextPartIndex) {
textBlockBuilder.cacheControl(userCacheControl);
cacheResolver.useCacheBlock();
}
contentBlocks.add(ContentBlockParam.ofText(textBlockBuilder.build()));
}
contentBlocks.add(ContentBlockParam.ofText(textBlockBuilder.build()));
}

if (hasMedia) {
for (Media media : userMessage.getMedia()) {
contentBlocks.add(getContentBlockParamByMedia(media));
else if (contentPart instanceof ContentPart.MediaPart mediaPart) {
contentBlocks.add(getContentBlockParamByMedia(mediaPart.media()));
}
}

Expand Down Expand Up @@ -1347,6 +1358,20 @@ private WebSearchTool20260209 toSdkWebSearchTool(AnthropicWebSearchTool webSearc
return sdkBuilder.build();
}

/**
* Index of the last content part that produces a text block, or -1 if there is none.
* Empty text parts are skipped when building blocks, so they cannot carry the cache
* control marker either.
*/
private static int lastNonEmptyTextPartIndex(List<ContentPart> contentParts) {
for (int i = contentParts.size() - 1; i >= 0; i--) {
if (contentParts.get(i) instanceof ContentPart.TextPart textPart && !textPart.text().isEmpty()) {
return i;
}
}
return -1;
}

/**
* Converts a Spring AI {@link Media} object to an Anthropic SDK
* {@link ContentBlockParam}. Supports images (PNG, JPEG, GIF, WebP) and PDF
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
import org.springframework.ai.chat.observation.DefaultChatModelObservationConvention;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.content.ContentPart;
import org.springframework.ai.content.Media;
import org.springframework.ai.model.tool.ToolCallingManager;
import org.springframework.ai.observation.conventions.AiProvider;
Expand Down Expand Up @@ -288,14 +289,24 @@ ConverseRequest createRequest(Prompt prompt) {
List<ContentBlock> contents = new ArrayList<>();
if (message instanceof UserMessage) {
var userMessage = (UserMessage) message;
contents.add(ContentBlock.fromText(userMessage.getText()));

if (!CollectionUtils.isEmpty(userMessage.getMedia())) {
List<ContentBlock> mediaContent = userMessage.getMedia()
.stream()
.map(this::mapMediaToContentBlock)
.toList();
contents.addAll(mediaContent);
// Walking the content parts preserves the caller's text/media
// ordering,
// which Bedrock's content block list expresses directly. For a
// message
// built in the flat form the parts are the text followed by the
// media.
for (ContentPart contentPart : userMessage.getContentParts()) {
if (contentPart instanceof ContentPart.TextPart textPart) {
// Bedrock rejects an empty text block, so blank text is
// skipped
// rather than sent as an empty content block.
if (StringUtils.hasText(textPart.text())) {
contents.add(ContentBlock.fromText(textPart.text()));
}
}
else if (contentPart instanceof ContentPart.MediaPart mediaPart) {
contents.add(mapMediaToContentBlock(mediaPart.media()));
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
import org.springframework.ai.chat.observation.ChatModelObservationDocumentation;
import org.springframework.ai.chat.observation.DefaultChatModelObservationConvention;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.content.ContentPart;
import org.springframework.ai.content.Media;
import org.springframework.ai.google.genai.cache.GoogleGenAiCachedContentService;
import org.springframework.ai.google.genai.common.GoogleGenAiConstants;
Expand Down Expand Up @@ -247,6 +248,25 @@ List<Part> messageToGeminiParts(Message message) {
return parts;
}
else if (message instanceof UserMessage userMessage) {
// Ordered content maps one-to-one onto Gemini's part list, so a prompt that
// interleaves text and media survives verbatim. Never empty for a message
// carrying any text or media, so the branch below is only reached for a
// message
// with neither.
List<ContentPart> contentParts = userMessage.getContentParts();
if (!contentParts.isEmpty()) {
List<Part> parts = new ArrayList<>(contentParts.size());
for (ContentPart contentPart : contentParts) {
if (contentPart instanceof ContentPart.TextPart textPart) {
parts.add(Part.fromText(textPart.text()));
}
else if (contentPart instanceof ContentPart.MediaPart mediaPart) {
parts.add(mediaToPart(mediaPart.media()));
}
}
return parts;
}

List<Part> parts = new ArrayList<>();
if (userMessage.getText() != null) {
parts.add(Part.fromText(userMessage.getText()));
Expand Down Expand Up @@ -319,31 +339,25 @@ else if (message instanceof ToolResponseMessage toolResponseMessage) {
}
}

private static List<Part> mediaToParts(Collection<Media> media) {
List<Part> parts = new ArrayList<>();

List<Part> mediaParts = media.stream().map(mediaData -> {
Object data = mediaData.getData();
String mimeType = mediaData.getMimeType().toString();
private static Part mediaToPart(Media media) {
Object data = media.getData();
String mimeType = media.getMimeType().toString();

if (data instanceof byte[]) {
return Part.fromBytes((byte[]) data, mimeType);
}
else if (data instanceof URI || data instanceof String) {
// Handle URI or String URLs
String uri = data.toString();
return Part.fromUri(uri, mimeType);
}
else {
throw new IllegalArgumentException("Unsupported media data type: " + data.getClass());
}
}).toList();

if (!CollectionUtils.isEmpty(mediaParts)) {
parts.addAll(mediaParts);
if (data instanceof byte[]) {
return Part.fromBytes((byte[]) data, mimeType);
}
else if (data instanceof URI || data instanceof String) {
// Handle URI or String URLs
String uri = data.toString();
return Part.fromUri(uri, mimeType);
}
else {
throw new IllegalArgumentException("Unsupported media data type: " + data.getClass());
}
}

return parts;
private static List<Part> mediaToParts(Collection<Media> media) {
return media.stream().map(GoogleGenAiChatModel::mediaToPart).toList();
}

// Helper methods for JSON/Map conversion
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.content.ContentPart;
import org.springframework.ai.content.Media;
import org.springframework.ai.google.genai.GoogleGenAiChatModel.GeminiRequest;
import org.springframework.ai.google.genai.common.GoogleGenAiServiceTier;
Expand Down Expand Up @@ -117,6 +118,90 @@ public void createRequestWithSystemMessage() {
assertThat(mediaPart.fileData().isPresent()).isTrue();
}

@Test
public void createRequestWithInterleavedContentParts() {

var image1 = Media.builder()
.mimeType(MimeTypeUtils.IMAGE_JPEG)
.data(URI.create("http://example.com/page1.jpg"))
.build();
var image2 = Media.builder()
.mimeType(MimeTypeUtils.IMAGE_JPEG)
.data(URI.create("http://example.com/page2.jpg"))
.build();

// A per-page prompt: each image must stay directly after its own page marker.
var userMessage = UserMessage.builder()
.contentParts(ContentPart.text("--- page 1 ---"), ContentPart.media(image1),
ContentPart.text("--- page 2 ---"), ContentPart.media(image2))
.build();

var client = GoogleGenAiChatModel.builder().genAiClient(this.genAiClient).build();

GeminiRequest request = client.createGeminiRequest(
new Prompt(List.of(userMessage), GoogleGenAiChatOptions.builder().model("DEFAULT_MODEL").build()));

assertThat(request.contents()).hasSize(1);
List<Part> parts = request.contents().get(0).parts().orElse(List.of());
assertThat(parts).hasSize(4);

assertThat(parts.get(0).text().orElse("")).isEqualTo("--- page 1 ---");
assertThat(parts.get(1).fileData()).isPresent();
assertThat(parts.get(1).fileData().get().fileUri().orElse("")).isEqualTo("http://example.com/page1.jpg");
assertThat(parts.get(2).text().orElse("")).isEqualTo("--- page 2 ---");
assertThat(parts.get(3).fileData()).isPresent();
assertThat(parts.get(3).fileData().get().fileUri().orElse("")).isEqualTo("http://example.com/page2.jpg");
}

@Test
public void createRequestWithContentPartsAndSystemMessage() {

var systemMessage = new SystemMessage("System Message Text");
var userMessage = UserMessage.builder()
.contentParts(ContentPart.text("first"), ContentPart.text("second"))
.build();

var client = GoogleGenAiChatModel.builder().genAiClient(this.genAiClient).build();

GeminiRequest request = client.createGeminiRequest(new Prompt(List.of(systemMessage, userMessage),
GoogleGenAiChatOptions.builder().model("DEFAULT_MODEL").build()));

// The system message is still hoisted out of contents into systemInstruction.
assertThat(request.config().systemInstruction()).isPresent();
assertThat(request.config().systemInstruction().get().parts().get().get(0).text().orElse(""))
.isEqualTo("System Message Text");

assertThat(request.contents()).hasSize(1);
List<Part> parts = request.contents().get(0).parts().orElse(List.of());
assertThat(parts).hasSize(2);
assertThat(parts.get(0).text().orElse("")).isEqualTo("first");
assertThat(parts.get(1).text().orElse("")).isEqualTo("second");
}

@Test
public void createRequestWithLegacyTextAndMediaIsUnchanged() {

// Pins the flat text-plus-media path: text first, then all media. A regression in
// how content parts are derived would show up here.
var userMessage = UserMessage.builder()
.text("User Message Text")
.media(List
.of(Media.builder().mimeType(MimeTypeUtils.IMAGE_PNG).data(URI.create("http://example.com")).build()))
.build();

var client = GoogleGenAiChatModel.builder().genAiClient(this.genAiClient).build();

GeminiRequest request = client.createGeminiRequest(
new Prompt(List.of(userMessage), GoogleGenAiChatOptions.builder().model("DEFAULT_MODEL").build()));

assertThat(request.contents()).hasSize(1);
List<Part> parts = request.contents().get(0).parts().orElse(List.of());
assertThat(parts).hasSize(2);
assertThat(parts.get(0).text().orElse("")).isEqualTo("User Message Text");
assertThat(parts.get(1).fileData()).isPresent();
assertThat(parts.get(1).fileData().get().fileUri().orElse("")).isEqualTo("http://example.com");
}

@Test
public void promptOptionsTools() {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package org.springframework.ai.mistralai;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -49,6 +50,7 @@
import org.springframework.ai.chat.observation.ChatModelObservationDocumentation;
import org.springframework.ai.chat.observation.DefaultChatModelObservationConvention;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.content.ContentPart;
import org.springframework.ai.content.Media;
import org.springframework.ai.mistralai.api.MistralAiApi;
import org.springframework.ai.mistralai.api.MistralAiApi.ChatCompletion;
Expand Down Expand Up @@ -467,20 +469,26 @@ private ChatCompletionMessage createSystemChatCompletionMessage(Message message)
}

private ChatCompletionMessage createUserChatCompletionMessage(Message message) {
var content = message.getText();
Assert.state(content != null, "content must not be null");

if (message instanceof UserMessage userMessage && !CollectionUtils.isEmpty(userMessage.getMedia())) {
// @formatter:off
var contentChunks = Stream.<ChatCompletionMessage.ContentChunk>concat(
Stream.of(new ChatCompletionMessage.TextChunk(content)),
this.mapToImageUrlChunks(userMessage)
).toList();
// @formatter:on
// Ordered content is handled before the non-null text assertion below, since a
// message whose content starts with media has no leading text to assert on.
if (message instanceof UserMessage userMessage
&& (userMessage.hasInterleavedContent() || !CollectionUtils.isEmpty(userMessage.getMedia()))) {
var contentChunks = new ArrayList<ChatCompletionMessage.ContentChunk>();
for (ContentPart contentPart : userMessage.getContentParts()) {
if (contentPart instanceof ContentPart.TextPart textPart) {
contentChunks.add(new ChatCompletionMessage.TextChunk(textPart.text()));
}
else if (contentPart instanceof ContentPart.MediaPart mediaPart) {
contentChunks.add(this.mapToImageUrlChunk(mediaPart.media()));
}
}

return new ChatCompletionMessage(contentChunks, ChatCompletionMessage.Role.USER);
}

var content = message.getText();
Assert.state(content != null, "content must not be null");

return new ChatCompletionMessage(content, ChatCompletionMessage.Role.USER);
}

Expand All @@ -490,10 +498,6 @@ private ToolCall mapToolCall(AssistantMessage.ToolCall toolCall) {
return new ToolCall(toolCall.id(), toolCall.type(), function, null);
}

private Stream<ChatCompletionMessage.ImageUrlChunk> mapToImageUrlChunks(UserMessage userMessage) {
return userMessage.getMedia().stream().map(this::mapToImageUrlChunk);
}

private ChatCompletionMessage.ImageUrlChunk mapToImageUrlChunk(Media media) {
return new ChatCompletionMessage.ImageUrlChunk(this.fromMediaData(media.getMimeType(), media.getData()));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,7 @@ void chatCompletionRequestWithOptionsTest() {

@Test
void createChatCompletionMessagesWithUserMessage() {
var userMessage = new UserMessage(TEXT_CONTENT);
userMessage.getMedia().add(IMAGE_MEDIA);
var userMessage = UserMessage.builder().text(TEXT_CONTENT).media(IMAGE_MEDIA).build();
var prompt = createPrompt(userMessage);
var chatCompletionRequest = this.chatModel.createRequest(prompt, false);
verifyUserChatCompletionMessages(chatCompletionRequest.messages());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,13 @@ OllamaApi.ChatRequest ollamaChatRequest(Prompt prompt, boolean stream) {
return List.of(OllamaApi.Message.builder(Role.SYSTEM).content(message.getText()).build());
}
else if (message.getMessageType() == MessageType.USER) {
// Ollama's message format is a text plus a separate image list, so it
// cannot
// express an ordering between the two. Ordered content parts are
// therefore
// flattened to text-then-images via getText()/getMedia(), which is the
// most
// this wire format can carry.
var messageBuilder = OllamaApi.Message.builder(Role.USER).content(message.getText());
if (message instanceof UserMessage userMessage) {
if (!CollectionUtils.isEmpty(userMessage.getMedia())) {
Expand Down
Loading