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
