This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-26208-spring-ai-embeddings-list in repository https://gitbox.apache.org/repos/asf/camel.git
commit caea45dbc14b83decc3379cfc75a9c51060a8ea1 Author: Claus Ibsen <[email protected]> AuthorDate: Tue Nov 3 00:23:30 2026 +0100 CAMEL-26208: camel-spring-ai-embeddings - a List body always gives a List of vectors, and its elements are converted to text A List body with one text gave a single float[] instead of a List<float[]>, so the output shape depended on the list size. A List body now always gives a List<float[]> with the CamelSpringAiEmbeddings and CamelSpringAiEmbeddingInputTexts headers. A List body was cast to List<String> unchecked, so a List of other objects failed with a ClassCastException inside Spring AI. Each element is now converted to a String, and a null element fails with an IllegalArgumentException. An empty List gives an empty List without calling the model (Ollama rejects an empty request). Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../catalog/components/spring-ai-embeddings.json | 4 +- .../docs/spring-ai-embeddings-component.adoc | 6 ++- .../springai/embeddings/spring-ai-embeddings.json | 4 +- .../main/docs/spring-ai-embeddings-component.adoc | 6 ++- .../embeddings/SpringAiEmbeddingsEndpoint.java | 10 ++-- .../embeddings/SpringAiEmbeddingsProducer.java | 25 ++++++++-- .../SpringAiEmbeddingsComponentTest.java | 57 ++++++++++++++++++++++ .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 11 +++++ 8 files changed, 109 insertions(+), 14 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/spring-ai-embeddings.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/spring-ai-embeddings.json index 6b33fa8447b1..e182c253f1ef 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/spring-ai-embeddings.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/spring-ai-embeddings.json @@ -38,8 +38,8 @@ "CamelSpringAiEmbeddingInputTexts": { "index": 5, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List<String>", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "List of input texts that were embedded", "constantName": "org.apache.camel.component.springai.embeddings.SpringAiEmbeddingsHeaders#INPUT_TEXTS" } }, "bodyTypes": { - "producerIn": { "carriers": [ "any" ], "description": "The text to embed, or a List of texts for a batch. Another body is converted to text (a Map or a POJO as its toString()), and no body fails" }, - "producerOut": { "carriers": [ "list" ], "description": "The vector as a float[] for one text, also for a List with one text, and a List of float[] for a List of more texts. The Embedding and the input text are in headers, which spring-ai-vector-store reads" } + "producerIn": { "carriers": [ "any" ], "description": "The text to embed, or a List of texts for a batch. Another body, and each element of a List, is converted to text (a Map or a POJO as its toString()); no body, or a null element in a List, fails" }, + "producerOut": { "carriers": [ "list" ], "description": "The vector as a float[] for one text, and a List of float[] for a List of texts, also for a List with one text or none. The Embedding and the input text are in headers, which spring-ai-vector-store reads" } }, "properties": { "embeddingId": { "index": 0, "kind": "path", "displayName": "Embedding 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-embeddings-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-ai-embeddings-component.adoc index ecd88fd3fca8..99ab8537b98c 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-ai-embeddings-component.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-ai-embeddings-component.adoc @@ -50,6 +50,9 @@ The component accepts two types of input: * **Single text**: A String containing the text to embed * **Batch processing**: A List<String> containing multiple texts to embed at once +Another body, and each element of a List, is converted to a String. A `null` element in a List fails the exchange +with an `IllegalArgumentException`. + The embedding vectors are returned in the message body, with additional metadata available in headers. === Single Text Example @@ -145,7 +148,8 @@ YAML:: ---- ==== -When processing a List<String>, the component returns: +When processing a List<String>, the component returns the following, also for a List with a single text. An empty +List gives an empty List, without calling the embedding model. * **Message Body**: List of embedding vectors as `List<float[]>` * **Headers**: diff --git a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/generated/resources/META-INF/org/apache/camel/component/springai/embeddings/spring-ai-embeddings.json b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/generated/resources/META-INF/org/apache/camel/component/springai/embeddings/spring-ai-embeddings.json index 6b33fa8447b1..e182c253f1ef 100644 --- a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/generated/resources/META-INF/org/apache/camel/component/springai/embeddings/spring-ai-embeddings.json +++ b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/generated/resources/META-INF/org/apache/camel/component/springai/embeddings/spring-ai-embeddings.json @@ -38,8 +38,8 @@ "CamelSpringAiEmbeddingInputTexts": { "index": 5, "kind": "header", "displayName": "", "group": "producer", "label": "", "required": false, "javaType": "java.util.List<String>", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "List of input texts that were embedded", "constantName": "org.apache.camel.component.springai.embeddings.SpringAiEmbeddingsHeaders#INPUT_TEXTS" } }, "bodyTypes": { - "producerIn": { "carriers": [ "any" ], "description": "The text to embed, or a List of texts for a batch. Another body is converted to text (a Map or a POJO as its toString()), and no body fails" }, - "producerOut": { "carriers": [ "list" ], "description": "The vector as a float[] for one text, also for a List with one text, and a List of float[] for a List of more texts. The Embedding and the input text are in headers, which spring-ai-vector-store reads" } + "producerIn": { "carriers": [ "any" ], "description": "The text to embed, or a List of texts for a batch. Another body, and each element of a List, is converted to text (a Map or a POJO as its toString()); no body, or a null element in a List, fails" }, + "producerOut": { "carriers": [ "list" ], "description": "The vector as a float[] for one text, and a List of float[] for a List of texts, also for a List with one text or none. The Embedding and the input text are in headers, which spring-ai-vector-store reads" } }, "properties": { "embeddingId": { "index": 0, "kind": "path", "displayName": "Embedding 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-embeddings/src/main/docs/spring-ai-embeddings-component.adoc b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/docs/spring-ai-embeddings-component.adoc index ecd88fd3fca8..99ab8537b98c 100644 --- a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/docs/spring-ai-embeddings-component.adoc +++ b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/docs/spring-ai-embeddings-component.adoc @@ -50,6 +50,9 @@ The component accepts two types of input: * **Single text**: A String containing the text to embed * **Batch processing**: A List<String> containing multiple texts to embed at once +Another body, and each element of a List, is converted to a String. A `null` element in a List fails the exchange +with an `IllegalArgumentException`. + The embedding vectors are returned in the message body, with additional metadata available in headers. === Single Text Example @@ -145,7 +148,8 @@ YAML:: ---- ==== -When processing a List<String>, the component returns: +When processing a List<String>, the component returns the following, also for a List with a single text. An empty +List gives an empty List, without calling the embedding model. * **Message Body**: List of embedding vectors as `List<float[]>` * **Headers**: diff --git a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsEndpoint.java b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsEndpoint.java index a0e3683f2a24..e82a65c38365 100644 --- a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsEndpoint.java +++ b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsEndpoint.java @@ -38,12 +38,12 @@ import org.apache.camel.support.DefaultEndpoint; Category.AI }, headersClass = SpringAiEmbeddingsHeaders.class) @BodyTypes(producerIn = @BodyType(value = Carrier.ANY, - description = "The text to embed, or a List of texts for a batch. Another body is" - + " converted to text (a Map or a POJO as its toString()), and no body" - + " fails"), + description = "The text to embed, or a List of texts for a batch. Another body, and" + + " each element of a List, is converted to text (a Map or a POJO as its" + + " toString()); no body, or a null element in a List, fails"), producerOut = @BodyType(value = Carrier.LIST, - description = "The vector as a float[] for one text, also for a List with one text," - + " and a List of float[] for a List of more texts. The Embedding and" + description = "The vector as a float[] for one text, and a List of float[] for a List" + + " of texts, also for a List with one text or none. The Embedding and" + " the input text are in headers, which spring-ai-vector-store reads")) public class SpringAiEmbeddingsEndpoint extends DefaultEndpoint { @Metadata(required = true) diff --git a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsProducer.java b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsProducer.java index f844951a8dcc..a3a64b8710ea 100644 --- a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsProducer.java +++ b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsProducer.java @@ -46,9 +46,28 @@ public class SpringAiEmbeddingsProducer extends DefaultProducer { // Support both single String and List<String> as input List<String> inputTexts; Object body = message.getBody(); + boolean batch = body instanceof List; - if (body instanceof List) { - inputTexts = (List<String>) body; + if (body instanceof List<?> list) { + inputTexts = new ArrayList<>(list.size()); + for (int i = 0; i < list.size(); i++) { + Object element = list.get(i); + String text = element != null + ? exchange.getContext().getTypeConverter().convertTo(String.class, exchange, element) : null; + if (text == null) { + throw new IllegalArgumentException( + "Element " + i + " of the List body must be text, but was: " + + (element != null ? element.getClass().getName() : "null")); + } + inputTexts.add(text); + } + if (inputTexts.isEmpty()) { + // nothing to embed, so do not call the model + message.setBody(new ArrayList<float[]>()); + message.setHeader(SpringAiEmbeddingsHeaders.EMBEDDINGS, new ArrayList<Embedding>()); + message.setHeader(SpringAiEmbeddingsHeaders.INPUT_TEXTS, inputTexts); + return; + } } else { String str = message.getBody(String.class); if (str != null) { @@ -65,7 +84,7 @@ public class SpringAiEmbeddingsProducer extends DefaultProducer { EmbeddingOptions.builder().build())); if (response.getResults() != null && !response.getResults().isEmpty()) { - if (inputTexts.size() == 1) { + if (!batch) { Embedding embedding = response.getResults().get(0); message.setBody(embedding.getOutput()); message.setHeader(SpringAiEmbeddingsHeaders.EMBEDDING_INDEX, embedding.getIndex()); diff --git a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/test/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsComponentTest.java b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/test/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsComponentTest.java index 3fe09376371d..61472572c4a4 100644 --- a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/test/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsComponentTest.java +++ b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/test/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsComponentTest.java @@ -19,8 +19,10 @@ package org.apache.camel.component.springai.embeddings; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import org.apache.camel.CamelContext; +import org.apache.camel.Exchange; import org.apache.camel.Message; import org.apache.camel.test.junit6.CamelTestSupport; import org.junit.jupiter.api.Test; @@ -35,6 +37,8 @@ import static org.assertj.core.api.Assertions.assertThat; public class SpringAiEmbeddingsComponentTest extends CamelTestSupport { + private final List<List<String>> requests = new CopyOnWriteArrayList<>(); + @Override protected CamelContext createCamelContext() throws Exception { CamelContext context = super.createCamelContext(); @@ -46,6 +50,7 @@ public class SpringAiEmbeddingsComponentTest extends CamelTestSupport { EmbeddingModel embeddingModel = new EmbeddingModel() { @Override public EmbeddingResponse call(EmbeddingRequest request) { + requests.add(request.getInstructions()); List<Embedding> embeddings = new ArrayList<>(); int index = 0; for (String text : request.getInstructions()) { @@ -134,4 +139,56 @@ public class SpringAiEmbeddingsComponentTest extends CamelTestSupport { assertThat(vectors.get(0)).hasSameSizeAs(vectors.get(1)); assertThat(vectors.get(0)).hasSameSizeAs(vectors.get(2)); } + + @Test + public void testListOfOneTextGivesList() { + Message result = fluentTemplate.to("spring-ai-embeddings:one") + .withBody(List.of("hello")) + .request(Message.class); + + assertThat(result.getBody()).isInstanceOf(List.class); + List<float[]> vectors = result.getBody(List.class); + assertThat(vectors).hasSize(1); + assertThat(vectors.get(0)).containsExactly(0.1f, 0.2f, 0.3f, 0.4f, 0.5f); + + assertThat(result.getHeader(SpringAiEmbeddingsHeaders.INPUT_TEXTS, List.class)).containsExactly("hello"); + assertThat(result.getHeader(SpringAiEmbeddingsHeaders.EMBEDDINGS, List.class)).hasSize(1); + assertThat(result.getHeader(SpringAiEmbeddingsHeaders.INPUT_TEXT)).isNull(); + } + + @Test + public void testListOfNonStringsIsConvertedToText() { + Message result = fluentTemplate.to("spring-ai-embeddings:convert") + .withBody(List.of(42, new StringBuilder("hello"))) + .request(Message.class); + + assertThat(requests).containsExactly(List.of("42", "hello")); + List<float[]> vectors = result.getBody(List.class); + assertThat(vectors).hasSize(2); + assertThat(result.getHeader(SpringAiEmbeddingsHeaders.INPUT_TEXTS, List.class)).containsExactly("42", "hello"); + } + + @Test + public void testListWithNullElementFails() { + Exchange exchange + = template.send("spring-ai-embeddings:null", e -> e.getMessage().setBody(Arrays.asList("hello", null))); + + assertThat(exchange.getException()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Element 1 of the List body"); + assertThat(requests).isEmpty(); + } + + @Test + public void testEmptyListGivesEmptyList() { + Message result = fluentTemplate.to("spring-ai-embeddings:empty") + .withBody(new ArrayList<String>()) + .request(Message.class); + + assertThat(result.getBody()).isInstanceOf(List.class); + assertThat(result.getBody(List.class)).isEmpty(); + assertThat(result.getHeader(SpringAiEmbeddingsHeaders.EMBEDDINGS, List.class)).isEmpty(); + assertThat(result.getHeader(SpringAiEmbeddingsHeaders.INPUT_TEXTS, List.class)).isEmpty(); + assertThat(requests).isEmpty(); + } } 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..72594f2371be 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 @@ -804,6 +804,17 @@ CamelContext of that provider. It now closes each of them only when no other `JC it returned the closed cache, whose operations throw `IllegalStateException`. This matters for code that calls a `JCacheManager` (for example one created by a custom `JCacheManagerFactory`) after it was closed. +=== camel-spring-ai-embeddings - a List body always gives a List of vectors + +A `List` body with one text now gives a `List<float[]>` with one vector, as a `List` of more texts does, and sets the +`CamelSpringAiEmbeddings` and `CamelSpringAiEmbeddingInputTexts` headers. Before, it gave a single `float[]` and set the +`CamelSpringAiEmbedding`, `CamelSpringAiEmbeddingIndex` and `CamelSpringAiEmbeddingInputText` headers, as for a `String` +body. A `String` body still gives a single `float[]`. + +Each element of a `List` body is now converted to a `String`, and a `null` element fails with an +`IllegalArgumentException`. Before, a `List` of other objects failed with a `ClassCastException` in the embedding +model. An empty `List` now gives an empty `List` without calling the embedding model. + === camel-spring-batch - the CamelSpringBatchJobName header needs jobFromHeader=true The producer now takes the job name from the `CamelSpringBatchJobName` header only when the endpoint has
