This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new d54a633393f9 CAMEL-26209: camel-spring-ai-image - an ImagePrompt body
uses the endpoint options and headers, and an Image with only a URL fails to
convert to bytes (#29034)
d54a633393f9 is described below
commit d54a633393f93df0144161ea37246405b5115d2c
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Nov 3 07:46:59 2026 +0100
CAMEL-26209: camel-spring-ai-image - an ImagePrompt body uses the endpoint
options and headers, and an Image with only a URL fails to convert to bytes
(#29034)
* CAMEL-26209: camel-spring-ai-image - an ImagePrompt body uses the end…
…point options and headers, and an Image with only a URL fails to convert
to bytes
An ImagePrompt body was sent as is, so n, width, height, model,
responseFormat
and style from the endpoint or the headers did not apply. They now fill the
generic options the prompt leaves unset; the prompt's own options still win.
Provider options such as OpenAiImageOptions are sent as is, as a generic
copy
would lose their provider settings.
The Image to byte[] and InputStream converters gave the bytes of the URL
text
for an Image with only a URL, so a route to file: wrote the URL into a .png.
They now fail with a message that suggests responseFormat=b64_json.
* CAMEL-26209: camel-spring-ai-image - explain how the generic ImageOpt…
…ions are detected, test that type, and say in the doc that the prompt
options win per option
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../camel/catalog/components/spring-ai-image.json | 4 +-
.../catalog/docs/spring-ai-image-component.adoc | 23 ++-
.../component/springai/image/spring-ai-image.json | 4 +-
.../src/main/docs/spring-ai-image-component.adoc | 23 ++-
.../springai/image/SpringAiImageConverter.java | 9 +-
.../springai/image/SpringAiImageEndpoint.java | 10 +-
.../springai/image/SpringAiImageProducer.java | 52 +++++--
.../springai/image/SpringAiImageConverterTest.java | 98 +++++++++++++
.../springai/image/SpringAiImageProducerTest.java | 159 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 12 ++
10 files changed, 368 insertions(+), 26 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/spring-ai-image.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/spring-ai-image.json
index c5bd30f01384..ee024a3cb6c1 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/spring-ai-image.json
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/spring-ai-image.json
@@ -47,8 +47,8 @@
"CamelSpringAiImageGenerations": { "index": 8, "kind": "header",
"displayName": "", "group": "producer", "label": "", "required": false,
"javaType": "java.util.List<org.springframework.ai.image.ImageGeneration>",
"deprecated": false, "deprecationNote": "", "autowired": false, "secret":
false, "description": "List of ImageGeneration objects for multiple image
results", "constantName":
"org.apache.camel.component.springai.image.SpringAiImageHeaders#IMAGE_GENERATIONS"
}
},
"bodyTypes": {
- "producerIn": { "carriers": [ "any" ], "description": "The prompt as text,
or an ImagePrompt, which keeps its own options: n, width and the other options
and headers then do not apply. Another body is converted to text, and no body
fails" },
- "producerOut": { "carriers": [ "pojo", "list" ], "description": "The
generated org.springframework.ai.image.Image, with a URL or base64 data, or a
List of Image when the model returns more than one (n above 1). An Image
converts to byte[] by decoding the base64 data; an Image with only a URL gives
the bytes of the URL text" }
+ "producerIn": { "carriers": [ "any" ], "description": "The prompt as text,
or an ImagePrompt. The options and headers fill the generic options the
ImagePrompt leaves unset; provider options such as OpenAiImageOptions are used
as is. Another body is converted to text, and no body fails" },
+ "producerOut": { "carriers": [ "pojo", "list" ], "description": "The
generated org.springframework.ai.image.Image, with a URL or base64 data, or a
List of Image when the model returns more than one (n above 1). An Image
converts to byte[] by decoding the base64 data; an Image with only a URL fails
to convert to byte[], so set responseFormat=b64_json to get the image data" }
},
"properties": {
"imageId": { "index": 0, "kind": "path", "displayName": "Image Id",
"group": "producer", "label": "", "required": true, "type": "string",
"javaType": "java.lang.String", "deprecated": false, "deprecationNote": "",
"autowired": false, "secret": false, "description": "The id" },
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-ai-image-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-ai-image-component.adoc
index c097f5613dc7..ca79543ecc0e 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-ai-image-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-ai-image-component.adoc
@@ -109,6 +109,24 @@ Exchange exchange = template.request("direct:generate", e
-> {
});
----
+==== With an ImagePrompt body
+
+When the message body is an `org.springframework.ai.image.ImagePrompt`, the
options it sets win, and the endpoint
+options and headers fill the options it leaves unset (`n`, `width`, `height`,
`model`, `responseFormat` and `style`).
+This applies to the generic options of a prompt (`new ImagePrompt(text)` or
options from `ImageOptionsBuilder`). The
+options of a provider, such as `OpenAiImageOptions`, are used as is, as a
generic copy of them would lose the
+provider settings (for example the OpenAI `quality`).
+
+._Java-only: Java Spring AI SDK API_
+[source,java]
+----
+// the options of the prompt win per option: the prompt width (1024) overrides
the endpoint width (512),
+// and the height the prompt leaves unset falls back to the endpoint height
(512)
+ImagePrompt prompt = new ImagePrompt("A red apple on a white table",
+ ImageOptionsBuilder.builder().width(1024).build());
+template.sendBody("spring-ai-image:gen?imageModel=#imageModel&width=512&height=512",
prompt);
+----
+
=== Saving Images to Files
The component includes a built-in `TypeConverter` that automatically converts
`Image` objects
@@ -158,7 +176,8 @@ YAML::
====
The `Image` → `byte[]` conversion decodes the base64 data automatically. Set
`responseFormat=b64_json` when the
-provider returns a URL by default, as the URL is not downloaded.
+provider returns a URL by default: the URL is not downloaded, so an `Image`
with only a URL fails to convert to
+`byte[]` or `InputStream`, and the route fails instead of writing the URL text
into the file.
=== Using with Ollama (Local Image Generation)
@@ -257,7 +276,7 @@ The component registers automatic type converters for
`Image` objects:
|===
| From | To | Behavior
-| `Image` | `byte[]` | Decodes base64 data. An `Image` with only a URL gives
the bytes of the URL text: the image is not downloaded
+| `Image` | `byte[]` | Decodes base64 data. An `Image` with only a URL fails
to convert, as the image is not downloaded: set `responseFormat=b64_json`
| `Image` | `InputStream` | Wraps the `byte[]` above in ByteArrayInputStream
| `Image` | `String` | Returns base64 string or URL
|===
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/generated/resources/META-INF/org/apache/camel/component/springai/image/spring-ai-image.json
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/generated/resources/META-INF/org/apache/camel/component/springai/image/spring-ai-image.json
index c5bd30f01384..ee024a3cb6c1 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/generated/resources/META-INF/org/apache/camel/component/springai/image/spring-ai-image.json
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/generated/resources/META-INF/org/apache/camel/component/springai/image/spring-ai-image.json
@@ -47,8 +47,8 @@
"CamelSpringAiImageGenerations": { "index": 8, "kind": "header",
"displayName": "", "group": "producer", "label": "", "required": false,
"javaType": "java.util.List<org.springframework.ai.image.ImageGeneration>",
"deprecated": false, "deprecationNote": "", "autowired": false, "secret":
false, "description": "List of ImageGeneration objects for multiple image
results", "constantName":
"org.apache.camel.component.springai.image.SpringAiImageHeaders#IMAGE_GENERATIONS"
}
},
"bodyTypes": {
- "producerIn": { "carriers": [ "any" ], "description": "The prompt as text,
or an ImagePrompt, which keeps its own options: n, width and the other options
and headers then do not apply. Another body is converted to text, and no body
fails" },
- "producerOut": { "carriers": [ "pojo", "list" ], "description": "The
generated org.springframework.ai.image.Image, with a URL or base64 data, or a
List of Image when the model returns more than one (n above 1). An Image
converts to byte[] by decoding the base64 data; an Image with only a URL gives
the bytes of the URL text" }
+ "producerIn": { "carriers": [ "any" ], "description": "The prompt as text,
or an ImagePrompt. The options and headers fill the generic options the
ImagePrompt leaves unset; provider options such as OpenAiImageOptions are used
as is. Another body is converted to text, and no body fails" },
+ "producerOut": { "carriers": [ "pojo", "list" ], "description": "The
generated org.springframework.ai.image.Image, with a URL or base64 data, or a
List of Image when the model returns more than one (n above 1). An Image
converts to byte[] by decoding the base64 data; an Image with only a URL fails
to convert to byte[], so set responseFormat=b64_json to get the image data" }
},
"properties": {
"imageId": { "index": 0, "kind": "path", "displayName": "Image Id",
"group": "producer", "label": "", "required": true, "type": "string",
"javaType": "java.lang.String", "deprecated": false, "deprecationNote": "",
"autowired": false, "secret": false, "description": "The id" },
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/main/docs/spring-ai-image-component.adoc
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/main/docs/spring-ai-image-component.adoc
index c097f5613dc7..ca79543ecc0e 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/main/docs/spring-ai-image-component.adoc
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/main/docs/spring-ai-image-component.adoc
@@ -109,6 +109,24 @@ Exchange exchange = template.request("direct:generate", e
-> {
});
----
+==== With an ImagePrompt body
+
+When the message body is an `org.springframework.ai.image.ImagePrompt`, the
options it sets win, and the endpoint
+options and headers fill the options it leaves unset (`n`, `width`, `height`,
`model`, `responseFormat` and `style`).
+This applies to the generic options of a prompt (`new ImagePrompt(text)` or
options from `ImageOptionsBuilder`). The
+options of a provider, such as `OpenAiImageOptions`, are used as is, as a
generic copy of them would lose the
+provider settings (for example the OpenAI `quality`).
+
+._Java-only: Java Spring AI SDK API_
+[source,java]
+----
+// the options of the prompt win per option: the prompt width (1024) overrides
the endpoint width (512),
+// and the height the prompt leaves unset falls back to the endpoint height
(512)
+ImagePrompt prompt = new ImagePrompt("A red apple on a white table",
+ ImageOptionsBuilder.builder().width(1024).build());
+template.sendBody("spring-ai-image:gen?imageModel=#imageModel&width=512&height=512",
prompt);
+----
+
=== Saving Images to Files
The component includes a built-in `TypeConverter` that automatically converts
`Image` objects
@@ -158,7 +176,8 @@ YAML::
====
The `Image` → `byte[]` conversion decodes the base64 data automatically. Set
`responseFormat=b64_json` when the
-provider returns a URL by default, as the URL is not downloaded.
+provider returns a URL by default: the URL is not downloaded, so an `Image`
with only a URL fails to convert to
+`byte[]` or `InputStream`, and the route fails instead of writing the URL text
into the file.
=== Using with Ollama (Local Image Generation)
@@ -257,7 +276,7 @@ The component registers automatic type converters for
`Image` objects:
|===
| From | To | Behavior
-| `Image` | `byte[]` | Decodes base64 data. An `Image` with only a URL gives
the bytes of the URL text: the image is not downloaded
+| `Image` | `byte[]` | Decodes base64 data. An `Image` with only a URL fails
to convert, as the image is not downloaded: set `responseFormat=b64_json`
| `Image` | `InputStream` | Wraps the `byte[]` above in ByteArrayInputStream
| `Image` | `String` | Returns base64 string or URL
|===
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/main/java/org/apache/camel/component/springai/image/SpringAiImageConverter.java
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/main/java/org/apache/camel/component/springai/image/SpringAiImageConverter.java
index aa31837075de..118ea7190b1d 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/main/java/org/apache/camel/component/springai/image/SpringAiImageConverter.java
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/main/java/org/apache/camel/component/springai/image/SpringAiImageConverter.java
@@ -27,7 +27,8 @@ import org.springframework.ai.image.Image;
* Type converters for Spring AI {@link Image} objects.
*
* Enables automatic conversion of generated images to byte arrays or input
streams, so they can be directly consumed by
- * components like the file component without an intermediate processor.
+ * components like the file component without an intermediate processor. An
image with only a URL (no base64 data)
+ * cannot be converted to bytes or a stream, as the URL is not downloaded.
*
* <pre>
* from("direct:generate")
@@ -47,7 +48,11 @@ public final class SpringAiImageConverter {
return Base64.getDecoder().decode(image.getB64Json());
}
if (image.getUrl() != null) {
- return image.getUrl().getBytes();
+ // the URL is not downloaded, and its text is not image data
+ throw new IllegalArgumentException(
+ "The Image has only a URL and no base64 data, so it cannot
be converted to bytes."
+ + " Set responseFormat=b64_json
on the spring-ai-image endpoint"
+ + " to get the image data, or
download the image from its URL");
}
return new byte[0];
}
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/main/java/org/apache/camel/component/springai/image/SpringAiImageEndpoint.java
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/main/java/org/apache/camel/component/springai/image/SpringAiImageEndpoint.java
index 7b0609c1ca9e..96882c0477dc 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/main/java/org/apache/camel/component/springai/image/SpringAiImageEndpoint.java
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/main/java/org/apache/camel/component/springai/image/SpringAiImageEndpoint.java
@@ -38,14 +38,16 @@ import org.apache.camel.support.DefaultEndpoint;
Category.AI
}, headersClass = SpringAiImageHeaders.class)
@BodyTypes(producerIn = @BodyType(value = Carrier.ANY,
- description = "The prompt as text, or an
ImagePrompt, which keeps its own options: n,"
- + " width and the other
options and headers then do not apply. Another"
- + " body is converted to text,
and no body fails"),
+ description = "The prompt as text, or an
ImagePrompt. The options and headers fill"
+ + " the generic options the
ImagePrompt leaves unset; provider options"
+ + " such as OpenAiImageOptions
are used as is. Another body is converted"
+ + " to text, and no body
fails"),
producerOut = @BodyType(value = { Carrier.POJO, Carrier.LIST },
description = "The generated
org.springframework.ai.image.Image, with a URL or base64"
+ " data, or a List of Image
when the model returns more than one (n"
+ " above 1). An Image
converts to byte[] by decoding the base64 data;"
- + " an Image with only a URL
gives the bytes of the URL text"))
+ + " an Image with only a URL
fails to convert to byte[], so set"
+ + " responseFormat=b64_json
to get the image data"))
public class SpringAiImageEndpoint extends DefaultEndpoint {
@Metadata(required = true)
@UriPath(description = "The id")
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/main/java/org/apache/camel/component/springai/image/SpringAiImageProducer.java
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/main/java/org/apache/camel/component/springai/image/SpringAiImageProducer.java
index 37db71b3c69a..7e7fbd3e4976 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/main/java/org/apache/camel/component/springai/image/SpringAiImageProducer.java
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/main/java/org/apache/camel/component/springai/image/SpringAiImageProducer.java
@@ -31,6 +31,14 @@ import org.springframework.ai.image.ImageResponse;
public class SpringAiImageProducer extends DefaultProducer {
+ // the type of the generic options of ImageOptionsBuilder and new
ImagePrompt(text), as opposed to the
+ // options of a provider such as OpenAiImageOptions. Spring AI (checked
with 2.0.2) has no public type or
+ // marker for them: ImageOptions is implemented by both, and the class the
builder creates
+ // (ImageOptionsBuilder.DefaultImageModelOptions) is package-private. So
the type is taken from the builder
+ // itself, which follows a rename. SpringAiImageProducerTest fails if the
builder or new ImagePrompt(text)
+ // stop creating options of this one type.
+ static final Class<?> GENERIC_OPTIONS_TYPE =
ImageOptionsBuilder.builder().build().getClass();
+
public SpringAiImageProducer(SpringAiImageEndpoint endpoint) {
super(endpoint);
}
@@ -49,11 +57,11 @@ public class SpringAiImageProducer extends DefaultProducer {
ImagePrompt imagePrompt;
if (body instanceof ImagePrompt prompt) {
- imagePrompt = prompt;
+ imagePrompt = mergeImageOptions(exchange, prompt);
} else {
String text = message.getBody(String.class);
if (text != null) {
- ImageOptions options = buildImageOptions(exchange);
+ ImageOptions options = buildImageOptions(exchange, null);
imagePrompt = new ImagePrompt(text, options);
} else {
throw new IllegalArgumentException(
@@ -83,38 +91,55 @@ public class SpringAiImageProducer extends DefaultProducer {
}
}
- private ImageOptions buildImageOptions(Exchange exchange) {
+ /**
+ * Fills the options an ImagePrompt leaves unset from the headers and the
endpoint configuration. The options of a
+ * provider (such as OpenAiImageOptions) are used as is, as a generic copy
would lose their provider settings.
+ */
+ private ImagePrompt mergeImageOptions(Exchange exchange, ImagePrompt
prompt) {
+ ImageOptions promptOptions = prompt.getOptions();
+ if (promptOptions != null && promptOptions.getClass() !=
GENERIC_OPTIONS_TYPE) {
+ return prompt;
+ }
+ return new ImagePrompt(prompt.getInstructions(),
buildImageOptions(exchange, promptOptions));
+ }
+
+ private ImageOptions buildImageOptions(Exchange exchange, ImageOptions
promptOptions) {
SpringAiImageConfiguration config = getEndpoint().getConfiguration();
ImageOptionsBuilder builder = ImageOptionsBuilder.builder();
- // Headers override configuration
- Integer n = getOption(exchange, SpringAiImageHeaders.N, Integer.class,
config.getN());
+ // The options of the prompt override headers, and headers override
configuration
+ Integer n = getOption(exchange, promptOptions != null ?
promptOptions.getN() : null, SpringAiImageHeaders.N,
+ Integer.class, config.getN());
if (n != null) {
builder.n(n);
}
- Integer width = getOption(exchange, SpringAiImageHeaders.WIDTH,
Integer.class, config.getWidth());
+ Integer width = getOption(exchange, promptOptions != null ?
promptOptions.getWidth() : null,
+ SpringAiImageHeaders.WIDTH, Integer.class, config.getWidth());
if (width != null) {
builder.width(width);
}
- Integer height = getOption(exchange, SpringAiImageHeaders.HEIGHT,
Integer.class, config.getHeight());
+ Integer height = getOption(exchange, promptOptions != null ?
promptOptions.getHeight() : null,
+ SpringAiImageHeaders.HEIGHT, Integer.class,
config.getHeight());
if (height != null) {
builder.height(height);
}
- String model = getOption(exchange, SpringAiImageHeaders.MODEL,
String.class, config.getModel());
+ String model = getOption(exchange, promptOptions != null ?
promptOptions.getModel() : null,
+ SpringAiImageHeaders.MODEL, String.class, config.getModel());
if (model != null) {
builder.model(model);
}
- String responseFormat = getOption(exchange,
SpringAiImageHeaders.RESPONSE_FORMAT, String.class,
- config.getResponseFormat());
+ String responseFormat = getOption(exchange, promptOptions != null ?
promptOptions.getResponseFormat() : null,
+ SpringAiImageHeaders.RESPONSE_FORMAT, String.class,
config.getResponseFormat());
if (responseFormat != null) {
builder.responseFormat(responseFormat);
}
- String style = getOption(exchange, SpringAiImageHeaders.STYLE,
String.class, config.getStyle());
+ String style = getOption(exchange, promptOptions != null ?
promptOptions.getStyle() : null,
+ SpringAiImageHeaders.STYLE, String.class, config.getStyle());
if (style != null) {
builder.style(style);
}
@@ -122,7 +147,10 @@ public class SpringAiImageProducer extends DefaultProducer
{
return builder.build();
}
- private <T> T getOption(Exchange exchange, String headerName, Class<T>
type, T configValue) {
+ private <T> T getOption(Exchange exchange, T promptValue, String
headerName, Class<T> type, T configValue) {
+ if (promptValue != null) {
+ return promptValue;
+ }
T headerValue = exchange.getIn().getHeader(headerName, type);
return headerValue != null ? headerValue : configValue;
}
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/test/java/org/apache/camel/component/springai/image/SpringAiImageConverterTest.java
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/test/java/org/apache/camel/component/springai/image/SpringAiImageConverterTest.java
new file mode 100644
index 000000000000..6b4c20c3ebf4
--- /dev/null
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/test/java/org/apache/camel/component/springai/image/SpringAiImageConverterTest.java
@@ -0,0 +1,98 @@
+/*
+ * 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.camel.component.springai.image;
+
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.List;
+
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.Exchange;
+import org.apache.camel.TypeConversionException;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.springframework.ai.image.Image;
+import org.springframework.ai.image.ImageGeneration;
+import org.springframework.ai.image.ImageModel;
+import org.springframework.ai.image.ImageResponse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+public class SpringAiImageConverterTest extends CamelTestSupport {
+
+ private static final String URL = "https://example.com/generated.png";
+
+ @TempDir
+ Path tempDir;
+
+ @BindToRegistry("imageModel")
+ private final ImageModel imageModel
+ = prompt -> new ImageResponse(List.of(new ImageGeneration(new
Image(URL, null))));
+
+ @Test
+ public void base64ImageConvertsToTheDecodedBytes() throws Exception {
+ Image image = new Image(null, "aGVsbG8=");
+
+ assertThat(context.getTypeConverter().convertTo(byte[].class, image))
+ .isEqualTo("hello".getBytes(StandardCharsets.UTF_8));
+ try (InputStream is =
context.getTypeConverter().convertTo(InputStream.class, image)) {
+
assertThat(is.readAllBytes()).isEqualTo("hello".getBytes(StandardCharsets.UTF_8));
+ }
+ assertThat(context.getTypeConverter().convertTo(String.class,
image)).isEqualTo("aGVsbG8=");
+ }
+
+ @Test
+ public void urlOnlyImageFailsToConvertToBytes() {
+ Image image = new Image(URL, null);
+
+ assertThatThrownBy(() ->
context.getTypeConverter().convertTo(byte[].class, image))
+ .isInstanceOf(TypeConversionException.class)
+ .rootCause()
+ .hasMessageContaining("responseFormat=b64_json");
+ assertThatThrownBy(() ->
context.getTypeConverter().convertTo(InputStream.class, image))
+ .isInstanceOf(TypeConversionException.class)
+ .rootCause()
+ .hasMessageContaining("responseFormat=b64_json");
+ // the text of the image is still its URL
+ assertThat(context.getTypeConverter().convertTo(String.class,
image)).isEqualTo(URL);
+ }
+
+ @Test
+ public void urlOnlyImageIsNotWrittenToFile() {
+ Exchange out = template.send("direct:start", e ->
e.getMessage().setBody("A camel"));
+
+ assertThat(out.getException()).isNotNull();
+
assertThat(out.getException()).rootCause().hasMessageContaining("responseFormat=b64_json");
+ assertThat(tempDir.resolve("image.png")).doesNotExist();
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("direct:start")
+ .to("spring-ai-image:gen?imageModel=#imageModel")
+ .to("file:" + tempDir + "?fileName=image.png");
+ }
+ };
+ }
+}
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/test/java/org/apache/camel/component/springai/image/SpringAiImageProducerTest.java
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/test/java/org/apache/camel/component/springai/image/SpringAiImageProducerTest.java
new file mode 100644
index 000000000000..9e39ebb4886a
--- /dev/null
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/test/java/org/apache/camel/component/springai/image/SpringAiImageProducerTest.java
@@ -0,0 +1,159 @@
+/*
+ * 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.camel.component.springai.image;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+import org.springframework.ai.image.Image;
+import org.springframework.ai.image.ImageGeneration;
+import org.springframework.ai.image.ImageMessage;
+import org.springframework.ai.image.ImageModel;
+import org.springframework.ai.image.ImageOptions;
+import org.springframework.ai.image.ImageOptionsBuilder;
+import org.springframework.ai.image.ImagePrompt;
+import org.springframework.ai.image.ImageResponse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests the options the producer sends to the ImageModel, with a fake
ImageModel.
+ */
+public class SpringAiImageProducerTest extends CamelTestSupport {
+
+ private static final String ENDPOINT =
"spring-ai-image:gen?imageModel=#imageModel&n=2&width=512&height=256"
+ +
"&model=endpoint-model&responseFormat=b64_json&style=vivid";
+
+ private final List<ImagePrompt> prompts = new ArrayList<>();
+
+ @BindToRegistry("imageModel")
+ private final ImageModel imageModel = prompt -> {
+ prompts.add(prompt);
+ return new ImageResponse(List.of(new ImageGeneration(new Image(null,
"aGVsbG8="))));
+ };
+
+ @Test
+ public void textBodyUsesEndpointOptionsAndHeaders() {
+ template.sendBodyAndHeader(ENDPOINT, "A camel",
SpringAiImageHeaders.WIDTH, 1024);
+
+ assertThat(prompts).hasSize(1);
+
assertThat(prompts.get(0).getInstructions().get(0).getText()).isEqualTo("A
camel");
+ assertOptions(prompts.get(0).getOptions(), 2, 1024, 256,
"endpoint-model", "b64_json", "vivid");
+ }
+
+ @Test
+ public void imagePromptWithoutOptionsUsesEndpointOptions() {
+ template.sendBody(ENDPOINT, new ImagePrompt("A camel"));
+
+ assertThat(prompts).hasSize(1);
+
assertThat(prompts.get(0).getInstructions().get(0).getText()).isEqualTo("A
camel");
+ assertOptions(prompts.get(0).getOptions(), 2, 512, 256,
"endpoint-model", "b64_json", "vivid");
+ }
+
+ @Test
+ public void imagePromptWithNullOptionsUsesEndpointOptions() {
+ template.sendBody(ENDPOINT, new ImagePrompt(List.of(new
ImageMessage("A camel", 0.5f))));
+
+ assertThat(prompts).hasSize(1);
+ ImageMessage message = prompts.get(0).getInstructions().get(0);
+ assertThat(message.getText()).isEqualTo("A camel");
+ assertThat(message.getWeight()).isEqualTo(0.5f);
+ assertOptions(prompts.get(0).getOptions(), 2, 512, 256,
"endpoint-model", "b64_json", "vivid");
+ }
+
+ @Test
+ public void imagePromptOptionsWinOverHeadersAndEndpointOptions() {
+ ImageOptions own =
ImageOptionsBuilder.builder().width(2048).model("prompt-model").build();
+
+ template.sendBodyAndHeader(ENDPOINT, new ImagePrompt("A camel", own),
SpringAiImageHeaders.HEIGHT, 128);
+
+ assertThat(prompts).hasSize(1);
+ assertOptions(prompts.get(0).getOptions(), 2, 2048, 128,
"prompt-model", "b64_json", "vivid");
+ }
+
+ @Test
+ public void imagePromptWithProviderOptionsIsUsedAsIs() {
+ ImagePrompt prompt = new ImagePrompt("A camel", new
ProviderImageOptions());
+
+ template.sendBody(ENDPOINT, prompt);
+
+ assertThat(prompts).hasSize(1);
+ assertThat(prompts.get(0)).isSameAs(prompt);
+ }
+
+ @Test
+ public void
genericOptionsTypeIsTheTypeOfTheBuilderAndOfAPromptWithoutOptions() {
+ // the producer tells the generic options from those of a provider by
their class, as Spring AI has no
+ // public type for them: if this fails, Spring AI changed the type and
the producer must detect it otherwise
+
assertThat(ImageOptionsBuilder.builder().n(1).width(1024).model("m").style("s").build())
+ .as("options of
ImageOptionsBuilder").isExactlyInstanceOf(SpringAiImageProducer.GENERIC_OPTIONS_TYPE);
+ assertThat(new ImagePrompt("A camel").getOptions())
+ .as("options of new
ImagePrompt(text)").isExactlyInstanceOf(SpringAiImageProducer.GENERIC_OPTIONS_TYPE);
+ assertThat(new
ProviderImageOptions()).isNotInstanceOf(SpringAiImageProducer.GENERIC_OPTIONS_TYPE);
+ }
+
+ private static void assertOptions(
+ ImageOptions options, Integer n, Integer width, Integer height,
String model, String responseFormat,
+ String style) {
+ assertThat(options).isNotNull();
+ assertThat(options.getN()).isEqualTo(n);
+ assertThat(options.getWidth()).isEqualTo(width);
+ assertThat(options.getHeight()).isEqualTo(height);
+ assertThat(options.getModel()).isEqualTo(model);
+ assertThat(options.getResponseFormat()).isEqualTo(responseFormat);
+ assertThat(options.getStyle()).isEqualTo(style);
+ }
+
+ /**
+ * Stands for the options of a provider, such as OpenAiImageOptions, which
have settings of their own.
+ */
+ private static final class ProviderImageOptions implements ImageOptions {
+
+ @Override
+ public Integer getN() {
+ return null;
+ }
+
+ @Override
+ public String getModel() {
+ return "provider-model";
+ }
+
+ @Override
+ public Integer getWidth() {
+ return null;
+ }
+
+ @Override
+ public Integer getHeight() {
+ return null;
+ }
+
+ @Override
+ public String getResponseFormat() {
+ return null;
+ }
+
+ @Override
+ public String getStyle() {
+ return null;
+ }
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 0e829726f1d4..4e7f79160335 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -4533,6 +4533,18 @@ initial size of the buffer, and its default changed from
1048576 to 8192. The pa
numbers or booleans, such as `duration` or `disable_notification`, are now
sent; previously they were sent empty. The
text parts are written in UTF-8, as they declare.
+=== camel-spring-ai-image - an ImagePrompt body uses the endpoint options, and
a URL-only Image fails to convert
+
+With an `ImagePrompt` body, the endpoint options and headers (`n`, `width`,
`height`, `model`, `responseFormat` and
+`style`) now fill the generic options that the prompt leaves unset; the
options the prompt sets still win. Before, an
+`ImagePrompt` body was sent as is, and the endpoint options and headers did
not apply. The options of a provider, such
+as `OpenAiImageOptions`, are still sent as is.
+
+The type converter from an `Image` that has only a URL (no base64 data) to
`byte[]` or `InputStream` now fails with a
+message that suggests `responseFormat=b64_json`. Before, it gave the bytes of
the URL text, so a route such as
+`.to("spring-ai-image:gen").to("file:out?fileName=image.png")` wrote the URL
into the `.png` file. The conversion to
+`String` still gives the URL.
+
=== camel-spring-redis - commands the producer does not run fail, and a read
that finds nothing gives no body
The `spring-redis` producer now fails the exchange for a `CamelRedis.Command`
it does not run. Previously these did