This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch CAMEL-26209-spring-ai-image-options
in repository https://gitbox.apache.org/repos/asf/camel.git

commit fa341d665f5910fb4ef62e682a608fae7f552f28
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Nov 3 00:27:45 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
    
    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.
    
    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    |  22 ++-
 .../component/springai/image/spring-ai-image.json  |   4 +-
 .../src/main/docs/spring-ai-image-component.adoc   |  22 ++-
 .../springai/image/SpringAiImageConverter.java     |   9 +-
 .../springai/image/SpringAiImageEndpoint.java      |  10 +-
 .../springai/image/SpringAiImageProducer.java      |  48 +++++--
 .../springai/image/SpringAiImageConverterTest.java |  98 ++++++++++++++
 .../springai/image/SpringAiImageProducerTest.java  | 148 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  12 ++
 10 files changed, 351 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..6937b135de36 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,23 @@ 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]
+----
+// width=1024 from the prompt, height=512 from the endpoint
+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 +175,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 +275,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..6937b135de36 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,23 @@ 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]
+----
+// width=1024 from the prompt, height=512 from the endpoint
+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 +175,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 +275,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..46ec5134db77 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,10 @@ 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
+    private static final Class<?> GENERIC_OPTIONS_TYPE = 
ImageOptionsBuilder.builder().build().getClass();
+
     public SpringAiImageProducer(SpringAiImageEndpoint endpoint) {
         super(endpoint);
     }
@@ -49,11 +53,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 +87,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 +143,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..fd43560e41e9
--- /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,148 @@
+/*
+ * 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);
+    }
+
+    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 10f5a47c1b89..5f2003aac226 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
@@ -4449,6 +4449,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

Reply via email to