This is an automated email from the ASF dual-hosted git repository.
Croway 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 c05213c60fef CAMEL-24212: camel-spring-ai - upgrade to Spring AI 2.0
(#25354)
c05213c60fef is described below
commit c05213c60fef188ed2e55dfd46bddc51816df550
Author: Federico Mariani <[email protected]>
AuthorDate: Wed Aug 5 15:13:36 2026 +0200
CAMEL-24212: camel-spring-ai - upgrade to Spring AI 2.0 (#25354)
* CAMEL-24212: camel-spring-ai - upgrade to Spring AI 2.0
Spring AI 1.1 targets Spring Boot 3.5 and Spring Framework 6, while Camel
is already
on Spring Boot 4.1 and Spring Framework 7, which is the baseline Spring AI
2.0 was
designed for. The upgrade also aligns the MCP Java SDK that
camel-spring-ai-chat pulls
in transitively (0.18.2) with the 2.0.0 version already managed for
camel-mcp-server,
camel-mcp-server-api and camel-openai.
Main changes:
* spring-ai-advisors-vector-store was renamed to
spring-ai-vector-store-advisor.
* SpringAiChatMcpManager no longer binds to a Jackson generation: the stdio
transport
now takes McpJsonDefaults.getMapper() instead of constructing a Jackson 2
mapper,
so it works with whichever MCP JSON provider is on the classpath.
* ChatClient.ChatClientRequestSpec.options() now takes the options builder.
* ImageOptionsBuilder.N() was renamed to n().
* toolNames is resolved by the component: Spring AI 2.0 removed
ChatClientRequestSpec.toolNames(String...) together with
SpringBeanToolCallbackResolver.
Each name is looked up against a ToolCallbackResolver in the registry,
then against the
tools discovered via tags and configured via toolCallbacks, and finally
against a
ToolCallback bound in the registry under that name. An unresolvable name
now fails the
exchange with a message listing the available tools.
* Tool callbacks from all sources are deduplicated by tool name, since
Spring AI 2.0
rejects a ToolCallingChatOptions carrying two tools with the same name.
Tests: the Ollama model builders take options() rather than
defaultOptions(); the mocked
ChatModel must return non-null getOptions(); the retry configuration moved
to Spring
Framework 7 core retry; and SpringAiImageOllamaIT builds an openai-java SDK
client now
that spring-ai-openai is built on the official SDK.
Verified against a local Ollama: 91 chat tests, 7 embeddings, 16 vector
store (including
the Qdrant testcontainers IT) and the multimodal IT all pass.
SpringAiImageOllamaIT was
not executed because the x/flux2-klein:4b model was not available locally.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* CAMEL-24212: camel-spring-ai - disable Ollama thinking in the chat ITs
Reasoning models spend the whole token budget on the thinking channel and
answer with
empty content, which makes the IT assertions fail or hang: qwen3.5:2b-mlx
fails four
SpringAiChatParameterOverrideIT tests and then hangs
SpringAiChatSystemMessageIT on an
unbounded generation.
OllamaChatOptions.disableThinking() is new in Spring AI 2.0. Ollama accepts
think=false
on models without a thinking mode too, so this is safe for every model:
re-running the
whole chat suite with the default granite4:3b gives exactly the same result
as before
this change (91 tests, 1 error, 5 skipped - the error being
SpringAiChatMultimodalIT,
which needs a vision model).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* CAMEL-24212: camel-spring-ai - drop public from the new test class and
methods
Review feedback from davsclaus: AGENTS.md asks for package-private
visibility on test
classes and @Test methods. The overrides (doPreSetup, createRouteBuilder,
configure) keep
the supertype visibility, and the @Tool holder classes stay public so
Spring AI can
reflect over them.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* CAMEL-24212: camel-spring-ai - add test-execution.md
Documents how to run the ITs, following the convention already used by the
camel-ai
components. The suite needs two chat models rather than one: granite4:3b
for chat and tool
calling, and qwen3-vl:2b-instruct for the multimodal IT, since granite4 has
no vision
support.
Also records the things that are not discoverable from the code:
SpringAiChatVectorStoreIT,
SpringAiChatVectorMemoryIT and QdrantVectorStoreIT hardcode
embeddinggemma:300m while
ollama.embedding.model only drives SpringAiEmbeddingsIT,
SpringAiImageOllamaIT hardcodes
x/flux2-klein:4b, SpringAiChatMcpIT needs npx, and the SSE and Qdrant ITs
need Docker.
Includes a comparison of the models tried. Gemma 4 is the only family with
both vision and
tool calling, so it could in principle replace the pair, but gemma4:e2b-mlx
returns empty
content for ToolContext and MCP tool calls, and gemma4:12b-mlx passes
everything yet takes
10:06 min, which exceeds the 600s camel.failsafe.forkTimeout.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../camel/catalog/components/spring-ai-chat.json | 2 +-
.../catalog/docs/spring-ai-chat-component.adoc | 24 ++-
.../camel-spring-ai/camel-spring-ai-chat/pom.xml | 2 +-
.../component/springai/chat/spring-ai-chat.json | 2 +-
.../src/main/docs/spring-ai-chat-component.adoc | 24 ++-
.../springai/chat/SpringAiChatConfiguration.java | 14 +-
.../springai/chat/SpringAiChatProducer.java | 108 +++++++++---
.../springai/chat/mcp/SpringAiChatMcpManager.java | 5 +-
.../component/springai/chat/OllamaTestSupport.java | 6 +-
.../springai/chat/SpringAiChatComponentTest.java | 3 +
.../springai/chat/SpringAiChatToolNamesTest.java | 182 +++++++++++++++++++++
.../springai/chat/SpringAiChatVectorMemoryIT.java | 2 +-
.../springai/chat/SpringAiChatVectorStoreIT.java | 2 +-
.../springai/chat/SpringAiChatWrappedFileIT.java | 10 +-
.../springai/embeddings/OllamaTestSupport.java | 2 +-
.../springai/image/SpringAiImageProducer.java | 2 +-
.../springai/image/SpringAiImageOllamaIT.java | 48 +++---
.../springai/vectorstore/QdrantVectorStoreIT.java | 2 +-
.../camel-spring-ai/test-execution.md | 101 ++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_22.adoc | 40 +++++
.../dsl/SpringAiChatEndpointBuilderFactory.java | 9 +-
parent/pom.xml | 2 +-
22 files changed, 497 insertions(+), 95 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/spring-ai-chat.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/spring-ai-chat.json
index 550c46c808ef..b601f3abbeca 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/spring-ai-chat.json
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/spring-ai-chat.json
@@ -69,7 +69,7 @@
"chatOperation": { "index": 3, "kind": "parameter", "displayName": "Chat
Operation", "group": "producer", "label": "", "required": true, "type": "enum",
"javaType": "org.apache.camel.component.springai.chat.SpringAiChatOperations",
"enum": [ "CHAT_SINGLE_MESSAGE", "CHAT_SINGLE_MESSAGE_WITH_PROMPT",
"CHAT_MULTIPLE_MESSAGES" ], "deprecated": false, "deprecationNote": "",
"autowired": false, "secret": false, "defaultValue": "CHAT_SINGLE_MESSAGE",
"configurationClass": "org.apache.camel. [...]
"systemMessage": { "index": 4, "kind": "parameter", "displayName": "System
Message", "group": "producer", "label": "", "required": false, "type":
"string", "javaType": "java.lang.String", "deprecated": false, "autowired":
false, "secret": false, "configurationClass":
"org.apache.camel.component.springai.chat.SpringAiChatConfiguration",
"configurationField": "configuration", "description": "Default system message
to set context for the conversation. Can be overridden by the CamelSprin [...]
"tags": { "index": 5, "kind": "parameter", "displayName": "Tags", "group":
"producer", "label": "", "required": false, "type": "string", "javaType":
"java.lang.String", "deprecated": false, "autowired": false, "secret": false,
"configurationClass":
"org.apache.camel.component.springai.chat.SpringAiChatConfiguration",
"configurationField": "configuration", "description": "Tags for discovering and
calling Camel route tools" },
- "toolNames": { "index": 6, "kind": "parameter", "displayName": "Tool
Names", "group": "producer", "label": "", "required": false, "type": "string",
"javaType": "java.lang.String", "deprecated": false, "autowired": false,
"secret": false, "configurationClass":
"org.apache.camel.component.springai.chat.SpringAiChatConfiguration",
"configurationField": "configuration", "description": "Comma-separated tool
names for selecting tools by name via Spring AI's ToolCallbackResolver. This
enabl [...]
+ "toolNames": { "index": 6, "kind": "parameter", "displayName": "Tool
Names", "group": "producer", "label": "", "required": false, "type": "string",
"javaType": "java.lang.String", "deprecated": false, "autowired": false,
"secret": false, "configurationClass":
"org.apache.camel.component.springai.chat.SpringAiChatConfiguration",
"configurationField": "configuration", "description": "Comma-separated tool
names for selecting tools by name. Names are resolved against a
ToolCallbackResolv [...]
"userMessage": { "index": 7, "kind": "parameter", "displayName": "User
Message", "group": "producer", "label": "", "required": false, "type":
"string", "javaType": "java.lang.String", "deprecated": false, "autowired":
false, "secret": false, "configurationClass":
"org.apache.camel.component.springai.chat.SpringAiChatConfiguration",
"configurationField": "configuration", "description": "Default user message
text for multimodal requests. Can be combined with media data in the message b
[...]
"lazyStartProducer": { "index": 8, "kind": "parameter", "displayName":
"Lazy Start Producer", "group": "producer (advanced)", "label":
"producer,advanced", "required": false, "type": "boolean", "javaType":
"boolean", "deprecated": false, "autowired": false, "secret": false,
"defaultValue": false, "description": "Whether the producer should be started
lazy (on the first message). By starting lazy you can use this to allow
CamelContext and routes to startup in situations where a produc [...]
"advisors": { "index": 9, "kind": "parameter", "displayName": "Advisors",
"group": "advanced", "label": "advanced", "required": false, "type": "array",
"javaType":
"java.util.List<org.springframework.ai.chat.client.advisor.api.Advisor>",
"deprecated": false, "deprecationNote": "", "autowired": false, "secret":
false, "configurationClass":
"org.apache.camel.component.springai.chat.SpringAiChatConfiguration",
"configurationField": "configuration", "description": "List of custom advisor
[...]
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-ai-chat-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-ai-chat-component.adoc
index f8d54d1e417b..eb6b2d7d4ce4 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-ai-chat-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-ai-chat-component.adoc
@@ -934,9 +934,9 @@ In addition to Camel route tools (via `tags`), you can use
Spring AI `@Tool`-ann
==== Camel Spring Boot (Recommended)
-When using Camel Spring Boot, Spring AI's auto-configuration automatically
discovers `@Tool`-annotated
-methods from Spring beans via `ToolCallbackResolver`. Simply define your tool
as a Spring bean and
-reference it by name using `toolNames`:
+When using Camel Spring Boot, Spring AI's auto-configuration contributes a
`ToolCallbackResolver` built from the
+`ToolCallback` and `ToolCallbackProvider` beans in the application context.
Expose your `@Tool`-annotated bean as a
+`ToolCallbackProvider` and reference the tool by name using `toolNames`:
._Java-only: Spring `@Component` with `@Tool` annotation_
[source,java]
@@ -955,14 +955,22 @@ public class MyTools {
}
}
-// In your route — no manual callback registration needed
+@Bean
+ToolCallbackProvider myTools(MyTools myTools) {
+ return MethodToolCallbackProvider.builder().toolObjects(myTools).build();
+}
+
+// In your route
from("direct:chat")
.to("spring-ai-chat:chat?chatModel=#chatModel&toolNames=getCapital");
----
+NOTE: Spring AI 2.0 removed `SpringBeanToolCallbackResolver`, so a bare `@Bean
Function<...>` is no longer resolvable
+by bean name. Expose tools as `ToolCallback` or `ToolCallbackProvider` beans
instead.
+
==== Without Spring Boot
-Outside Spring Boot (e.g., in plain Camel or tests), there is no
`ToolCallbackResolver`.
+Outside Spring Boot (e.g., in plain Camel or tests), there is no
auto-configured `ToolCallbackResolver`.
You need to resolve the callbacks manually and bind them in the registry:
._Java-only: programmatic `ToolCallbackProvider` setup_
@@ -994,8 +1002,10 @@ template.requestBodyAndHeader("direct:chat", "What is the
capital of France?",
SpringAiChatConstants.TOOL_NAMES, "getCapital", String.class);
----
-Tool names are resolved via Spring AI's `ToolCallbackResolver`. You can
combine `toolNames` with `tags` —
-both tool sources are additive.
+Each name is resolved, in order, against a `ToolCallbackResolver` bound in the
registry, then against the tools
+discovered via `tags` and configured via `toolCallbacks`, and finally against
a `ToolCallback` bound in the registry
+under that name. A name that resolves nowhere fails the exchange with an
`IllegalArgumentException` listing the
+available tools. You can combine `toolNames` with `tags` — both tool sources
are additive.
=== Tool Context
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/pom.xml
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/pom.xml
index 6ffe7156082f..551addb92e09 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/pom.xml
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/pom.xml
@@ -72,7 +72,7 @@
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
- <artifactId>spring-ai-advisors-vector-store</artifactId>
+ <artifactId>spring-ai-vector-store-advisor</artifactId>
</dependency>
<!-- Spring AI MCP integration for Model Context Protocol client
support.
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/generated/resources/META-INF/org/apache/camel/component/springai/chat/spring-ai-chat.json
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/generated/resources/META-INF/org/apache/camel/component/springai/chat/spring-ai-chat.json
index 550c46c808ef..b601f3abbeca 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/generated/resources/META-INF/org/apache/camel/component/springai/chat/spring-ai-chat.json
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/generated/resources/META-INF/org/apache/camel/component/springai/chat/spring-ai-chat.json
@@ -69,7 +69,7 @@
"chatOperation": { "index": 3, "kind": "parameter", "displayName": "Chat
Operation", "group": "producer", "label": "", "required": true, "type": "enum",
"javaType": "org.apache.camel.component.springai.chat.SpringAiChatOperations",
"enum": [ "CHAT_SINGLE_MESSAGE", "CHAT_SINGLE_MESSAGE_WITH_PROMPT",
"CHAT_MULTIPLE_MESSAGES" ], "deprecated": false, "deprecationNote": "",
"autowired": false, "secret": false, "defaultValue": "CHAT_SINGLE_MESSAGE",
"configurationClass": "org.apache.camel. [...]
"systemMessage": { "index": 4, "kind": "parameter", "displayName": "System
Message", "group": "producer", "label": "", "required": false, "type":
"string", "javaType": "java.lang.String", "deprecated": false, "autowired":
false, "secret": false, "configurationClass":
"org.apache.camel.component.springai.chat.SpringAiChatConfiguration",
"configurationField": "configuration", "description": "Default system message
to set context for the conversation. Can be overridden by the CamelSprin [...]
"tags": { "index": 5, "kind": "parameter", "displayName": "Tags", "group":
"producer", "label": "", "required": false, "type": "string", "javaType":
"java.lang.String", "deprecated": false, "autowired": false, "secret": false,
"configurationClass":
"org.apache.camel.component.springai.chat.SpringAiChatConfiguration",
"configurationField": "configuration", "description": "Tags for discovering and
calling Camel route tools" },
- "toolNames": { "index": 6, "kind": "parameter", "displayName": "Tool
Names", "group": "producer", "label": "", "required": false, "type": "string",
"javaType": "java.lang.String", "deprecated": false, "autowired": false,
"secret": false, "configurationClass":
"org.apache.camel.component.springai.chat.SpringAiChatConfiguration",
"configurationField": "configuration", "description": "Comma-separated tool
names for selecting tools by name via Spring AI's ToolCallbackResolver. This
enabl [...]
+ "toolNames": { "index": 6, "kind": "parameter", "displayName": "Tool
Names", "group": "producer", "label": "", "required": false, "type": "string",
"javaType": "java.lang.String", "deprecated": false, "autowired": false,
"secret": false, "configurationClass":
"org.apache.camel.component.springai.chat.SpringAiChatConfiguration",
"configurationField": "configuration", "description": "Comma-separated tool
names for selecting tools by name. Names are resolved against a
ToolCallbackResolv [...]
"userMessage": { "index": 7, "kind": "parameter", "displayName": "User
Message", "group": "producer", "label": "", "required": false, "type":
"string", "javaType": "java.lang.String", "deprecated": false, "autowired":
false, "secret": false, "configurationClass":
"org.apache.camel.component.springai.chat.SpringAiChatConfiguration",
"configurationField": "configuration", "description": "Default user message
text for multimodal requests. Can be combined with media data in the message b
[...]
"lazyStartProducer": { "index": 8, "kind": "parameter", "displayName":
"Lazy Start Producer", "group": "producer (advanced)", "label":
"producer,advanced", "required": false, "type": "boolean", "javaType":
"boolean", "deprecated": false, "autowired": false, "secret": false,
"defaultValue": false, "description": "Whether the producer should be started
lazy (on the first message). By starting lazy you can use this to allow
CamelContext and routes to startup in situations where a produc [...]
"advisors": { "index": 9, "kind": "parameter", "displayName": "Advisors",
"group": "advanced", "label": "advanced", "required": false, "type": "array",
"javaType":
"java.util.List<org.springframework.ai.chat.client.advisor.api.Advisor>",
"deprecated": false, "deprecationNote": "", "autowired": false, "secret":
false, "configurationClass":
"org.apache.camel.component.springai.chat.SpringAiChatConfiguration",
"configurationField": "configuration", "description": "List of custom advisor
[...]
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/docs/spring-ai-chat-component.adoc
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/docs/spring-ai-chat-component.adoc
index f8d54d1e417b..eb6b2d7d4ce4 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/docs/spring-ai-chat-component.adoc
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/docs/spring-ai-chat-component.adoc
@@ -934,9 +934,9 @@ In addition to Camel route tools (via `tags`), you can use
Spring AI `@Tool`-ann
==== Camel Spring Boot (Recommended)
-When using Camel Spring Boot, Spring AI's auto-configuration automatically
discovers `@Tool`-annotated
-methods from Spring beans via `ToolCallbackResolver`. Simply define your tool
as a Spring bean and
-reference it by name using `toolNames`:
+When using Camel Spring Boot, Spring AI's auto-configuration contributes a
`ToolCallbackResolver` built from the
+`ToolCallback` and `ToolCallbackProvider` beans in the application context.
Expose your `@Tool`-annotated bean as a
+`ToolCallbackProvider` and reference the tool by name using `toolNames`:
._Java-only: Spring `@Component` with `@Tool` annotation_
[source,java]
@@ -955,14 +955,22 @@ public class MyTools {
}
}
-// In your route — no manual callback registration needed
+@Bean
+ToolCallbackProvider myTools(MyTools myTools) {
+ return MethodToolCallbackProvider.builder().toolObjects(myTools).build();
+}
+
+// In your route
from("direct:chat")
.to("spring-ai-chat:chat?chatModel=#chatModel&toolNames=getCapital");
----
+NOTE: Spring AI 2.0 removed `SpringBeanToolCallbackResolver`, so a bare `@Bean
Function<...>` is no longer resolvable
+by bean name. Expose tools as `ToolCallback` or `ToolCallbackProvider` beans
instead.
+
==== Without Spring Boot
-Outside Spring Boot (e.g., in plain Camel or tests), there is no
`ToolCallbackResolver`.
+Outside Spring Boot (e.g., in plain Camel or tests), there is no
auto-configured `ToolCallbackResolver`.
You need to resolve the callbacks manually and bind them in the registry:
._Java-only: programmatic `ToolCallbackProvider` setup_
@@ -994,8 +1002,10 @@ template.requestBodyAndHeader("direct:chat", "What is the
capital of France?",
SpringAiChatConstants.TOOL_NAMES, "getCapital", String.class);
----
-Tool names are resolved via Spring AI's `ToolCallbackResolver`. You can
combine `toolNames` with `tags` —
-both tool sources are additive.
+Each name is resolved, in order, against a `ToolCallbackResolver` bound in the
registry, then against the tools
+discovered via `tags` and configured via `toolCallbacks`, and finally against
a `ToolCallback` bound in the registry
+under that name. A name that resolves nowhere fails the exchange with an
`IllegalArgumentException` listing the
+available tools. You can combine `toolNames` with `tags` — both tool sources
are additive.
=== Tool Context
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatConfiguration.java
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatConfiguration.java
index f637ad6ebca7..69228e0ee019 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatConfiguration.java
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatConfiguration.java
@@ -121,8 +121,11 @@ public class SpringAiChatConfiguration implements
Cloneable {
@UriParam(label = "advanced", defaultValue = "1048576")
private long maxFileSize = 1024 * 1024; // 1MB default
- @UriParam(description = "Comma-separated tool names for selecting tools by
name via Spring AI's ToolCallbackResolver. "
- + "This enables selecting Spring @Tool annotated
beans or any registered ToolCallback by name.")
+ @UriParam(description = "Comma-separated tool names for selecting tools by
name. Names are resolved against a "
+ + "ToolCallbackResolver bound in the registry
(Spring Boot applications get Spring AI's "
+ + "auto-configured one), then against the tools
discovered via tags and configured via "
+ + "toolCallbacks, and finally against a
ToolCallback bound in the registry under that name. "
+ + "An unresolvable name fails the exchange.")
private String toolNames;
@UriParam(label = "advanced",
@@ -479,9 +482,10 @@ public class SpringAiChatConfiguration implements
Cloneable {
}
/**
- * Comma-separated tool names for selecting tools by name via Spring AI's
ToolCallbackResolver. This enables
- * selecting Spring @Tool annotated beans or any registered ToolCallback
by name, in addition to tag-based
- * discovery.
+ * Comma-separated tool names for selecting tools by name. Names are
resolved against a ToolCallbackResolver bound
+ * in the registry (Spring Boot applications get Spring AI's
auto-configured one), then against the tools discovered
+ * via tags and configured via toolCallbacks, and finally against a
ToolCallback bound in the registry under that
+ * name. An unresolvable name fails the exchange.
*/
public void setToolNames(String toolNames) {
this.toolNames = toolNames;
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatProducer.java
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatProducer.java
index c7836d197fcb..17089f36df31 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatProducer.java
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/SpringAiChatProducer.java
@@ -22,6 +22,7 @@ import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@@ -63,6 +64,8 @@ import
org.springframework.ai.converter.StructuredOutputConverter;
import org.springframework.ai.document.Document;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.ToolCallbackProvider;
+import org.springframework.ai.tool.resolution.ToolCallbackResolver;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.core.convert.support.DefaultConversionService;
@@ -554,10 +557,39 @@ public class SpringAiChatProducer extends DefaultProducer
{
// This ensures tool callbacks are properly included in the options
ToolCallingChatOptions.Builder optionsBuilder =
ToolCallingChatOptions.builder();
- // Add tool callbacks to the options builder if present
- if (!toolCallbacks.isEmpty()) {
- LOG.debug("Adding {} tool callbacks to ToolCallingChatOptions",
toolCallbacks.size());
- optionsBuilder.toolCallbacks(toolCallbacks);
+ // Collect the tool callbacks from every source keyed by tool name.
Spring AI 2.0 rejects duplicate
+ // tool names, so a tool reachable through more than one source must
be registered only once.
+ Map<String, ToolCallback> callbacksByName = new LinkedHashMap<>();
+ for (ToolCallback callback : toolCallbacks) {
+ callbacksByName.putIfAbsent(callback.getToolDefinition().name(),
callback);
+ }
+ List<ToolCallback> configuredCallbacks =
getEndpoint().getConfiguration().getToolCallbacks();
+ if (configuredCallbacks != null) {
+ for (ToolCallback callback : configuredCallbacks) {
+
callbacksByName.putIfAbsent(callback.getToolDefinition().name(), callback);
+ }
+ }
+
+ // Apply tool names by resolving them to ToolCallback instances
+ String toolNames =
exchange.getIn().getHeader(SpringAiChatConstants.TOOL_NAMES, String.class);
+ if (toolNames == null) {
+ toolNames = getEndpoint().getConfiguration().getToolNames();
+ }
+ if (toolNames != null && !toolNames.trim().isEmpty()) {
+ List<String> names = Arrays.stream(toolNames.split(","))
+ .map(String::trim)
+ .filter(s -> !s.isEmpty())
+ .toList();
+ for (ToolCallback callback : resolveToolCallbacksByName(names,
callbacksByName)) {
+
callbacksByName.putIfAbsent(callback.getToolDefinition().name(), callback);
+ }
+ LOG.debug("Resolved tool names {} to ToolCallbacks", names);
+ }
+
+ if (!callbacksByName.isEmpty()) {
+ LOG.debug("Adding {} tool callbacks to ToolCallingChatOptions: {}",
+ callbacksByName.size(), callbacksByName.keySet());
+
optionsBuilder.toolCallbacks(List.copyOf(callbacksByName.values()));
}
// Check configuration first
@@ -595,15 +627,8 @@ public class SpringAiChatProducer extends DefaultProducer {
optionsBuilder.topK(topK);
}
- ToolCallingChatOptions options = optionsBuilder.build();
- request.options(options);
-
- // Add direct ToolCallbacks if configured
- List<ToolCallback> configuredCallbacks =
getEndpoint().getConfiguration().getToolCallbacks();
- if (configuredCallbacks != null && !configuredCallbacks.isEmpty()) {
- request.toolCallbacks(configuredCallbacks);
- LOG.debug("Added {} configured ToolCallbacks",
configuredCallbacks.size());
- }
+ // Spring AI 2.0 takes the options builder rather than a built
ChatOptions instance
+ request.options(optionsBuilder);
// Add MCP tool callbacks if MCP servers are configured
if (mcpManager != null && mcpManager.getToolCallbackProvider() !=
null) {
@@ -611,20 +636,6 @@ public class SpringAiChatProducer extends DefaultProducer {
LOG.debug("Added MCP tool callback provider");
}
- // Apply tool names for resolution via ToolCallbackResolver
- String toolNames =
exchange.getIn().getHeader(SpringAiChatConstants.TOOL_NAMES, String.class);
- if (toolNames == null) {
- toolNames = getEndpoint().getConfiguration().getToolNames();
- }
- if (toolNames != null && !toolNames.trim().isEmpty()) {
- String[] names = Arrays.stream(toolNames.split(","))
- .map(String::trim)
- .filter(s -> !s.isEmpty())
- .toArray(String[]::new);
- request.toolNames(names);
- LOG.debug("Added {} tool names for resolution: {}", names.length,
Arrays.toString(names));
- }
-
// Apply tool context if configured
Map<String, Object> toolContext = getToolContext(exchange);
if (toolContext != null && !toolContext.isEmpty()) {
@@ -994,6 +1005,49 @@ public class SpringAiChatProducer extends DefaultProducer
{
return toolCallbacks;
}
+ /**
+ * Resolve the configured tool names to {@link ToolCallback} instances.
+ * <p>
+ * Spring AI 2.0 removed {@code
ChatClient.ChatClientRequestSpec.toolNames(String...)} and the Spring bean based
+ * {@code SpringBeanToolCallbackResolver}, so the names are resolved here
instead. Each name is looked up, in order,
+ * against:
+ * <ol>
+ * <li>a {@link ToolCallbackResolver} bound in the Camel registry - Spring
Boot applications get Spring AI's
+ * auto-configured resolver, which covers {@link ToolCallback} and {@link
ToolCallbackProvider} beans</li>
+ * <li>the tool callbacks already available to this endpoint, i.e. the
ones discovered from {@code tags} and the
+ * ones configured via {@code toolCallbacks}, matched on their tool
definition name</li>
+ * <li>a {@link ToolCallback} bound in the Camel registry under that
name</li>
+ * </ol>
+ *
+ * @param names the tool names to resolve
+ * @param availableByName the callbacks already available to
this endpoint, keyed by tool name
+ * @return the resolved callbacks, never {@code
null}
+ * @throws IllegalArgumentException if a name cannot be resolved
+ */
+ private List<ToolCallback> resolveToolCallbacksByName(List<String> names,
Map<String, ToolCallback> availableByName) {
+ final ToolCallbackResolver resolver =
getEndpoint().getCamelContext().getRegistry()
+ .findSingleByType(ToolCallbackResolver.class);
+
+ final List<ToolCallback> resolved = new ArrayList<>(names.size());
+ for (String name : names) {
+ ToolCallback callback = resolver != null ? resolver.resolve(name)
: null;
+ if (callback == null) {
+ callback = availableByName.get(name);
+ }
+ if (callback == null) {
+ callback =
getEndpoint().getCamelContext().getRegistry().lookupByNameAndType(name,
ToolCallback.class);
+ }
+ if (callback == null) {
+ throw new IllegalArgumentException(
+ "Cannot resolve tool name '" + name + "'. Available
tools: " + availableByName.keySet()
+ + ". Bind the tool as a
ToolCallback in the registry, expose it via"
+ + " toolCallbacks, or
discover it with tags.");
+ }
+ resolved.add(callback);
+ }
+ return resolved;
+ }
+
/**
* Discover tools registered via {@code ai-tool:} consumer endpoints in
the shared {@link AiToolRegistry}. Converts
* each {@link AiToolSpec} to a Spring AI {@link ToolCallback} via {@link
AiToolSpecToSpringAi}.
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/mcp/SpringAiChatMcpManager.java
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/mcp/SpringAiChatMcpManager.java
index ac3a8ec85455..919819855cf6 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/mcp/SpringAiChatMcpManager.java
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/main/java/org/apache/camel/component/springai/chat/mcp/SpringAiChatMcpManager.java
@@ -23,13 +23,12 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
-import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
import io.modelcontextprotocol.client.transport.ServerParameters;
import io.modelcontextprotocol.client.transport.StdioClientTransport;
-import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper;
+import io.modelcontextprotocol.json.McpJsonDefaults;
import io.modelcontextprotocol.spec.McpClientTransport;
import org.apache.camel.CamelContext;
import org.apache.camel.support.OAuthHelper;
@@ -165,7 +164,7 @@ public class SpringAiChatMcpManager {
if (args != null) {
paramsBuilder.args(List.of(args.split(",")));
}
- yield new StdioClientTransport(paramsBuilder.build(), new
JacksonMcpJsonMapper(new ObjectMapper()));
+ yield new StdioClientTransport(paramsBuilder.build(),
McpJsonDefaults.getMapper());
}
case "sse" -> {
String url = props.get("url");
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/OllamaTestSupport.java
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/OllamaTestSupport.java
index 6a8ad9a1fcbd..914074f644d0 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/OllamaTestSupport.java
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/OllamaTestSupport.java
@@ -56,14 +56,18 @@ public class OllamaTestSupport extends CamelTestSupport {
.baseUrl(OLLAMA.baseUrl())
.build();
+ // Reasoning models otherwise spend the whole token budget on the
thinking channel and answer with
+ // empty content, which makes the assertions in these tests fail or
hang. Ollama accepts think=false
+ // on models without a thinking mode too, so this is safe for every
model.
OllamaChatOptions ollamaOptions = OllamaChatOptions.builder()
.model(modelName())
.temperature(0.3)
+ .disableThinking()
.build();
ChatModel chatModel = OllamaChatModel.builder()
.ollamaApi(ollamaApi)
- .defaultOptions(ollamaOptions)
+ .options(ollamaOptions)
.build();
return chatModel;
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatComponentTest.java
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatComponentTest.java
index 6d290092d161..4a319aabd56c 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatComponentTest.java
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatComponentTest.java
@@ -25,6 +25,7 @@ import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
+import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -49,6 +50,8 @@ public class SpringAiChatComponentTest extends
CamelTestSupport {
Generation generation = new Generation(assistantMessage);
ChatResponse chatResponse = new ChatResponse(List.of(generation));
+ // Spring AI 2.0 requires ChatModel.getOptions() to be non-null:
ChatClient mutates it per request
+
when(mockChatModel.getOptions()).thenReturn(ChatOptions.builder().build());
when(mockChatModel.call(any(Prompt.class))).thenReturn(chatResponse);
}
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatToolNamesTest.java
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatToolNamesTest.java
new file mode 100644
index 000000000000..3bf62831da2d
--- /dev/null
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatToolNamesTest.java
@@ -0,0 +1,182 @@
+/*
+ * 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.chat;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.ai.chat.model.ChatResponse;
+import org.springframework.ai.chat.model.Generation;
+import org.springframework.ai.chat.prompt.ChatOptions;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.model.tool.ToolCallingChatOptions;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.ToolCallbackProvider;
+import org.springframework.ai.tool.annotation.Tool;
+import org.springframework.ai.tool.method.MethodToolCallbackProvider;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit test for selecting tools by name via the {@code toolNames} option and
the
+ * {@link SpringAiChatConstants#TOOL_NAMES} header.
+ *
+ * Spring AI 2.0 removed {@code
ChatClient.ChatClientRequestSpec.toolNames(String...)} and the Spring bean based
+ * {@code SpringBeanToolCallbackResolver}, so the component resolves the names
itself. These tests capture the
+ * {@link Prompt} handed to the {@link ChatModel} and assert which tool
callbacks made it through.
+ */
+class SpringAiChatToolNamesTest extends CamelTestSupport {
+
+ private ChatModel mockChatModel;
+
+ @Override
+ protected void doPreSetup() throws Exception {
+ super.doPreSetup();
+
+ mockChatModel = mock(ChatModel.class);
+
+ ChatResponse chatResponse = new ChatResponse(List.of(new
Generation(new AssistantMessage("mock answer"))));
+ // a tool capable model: ChatClient merges the request options into
the model defaults, so the
+ // defaults must be ToolCallingChatOptions for the tool callbacks to
survive the merge
+
when(mockChatModel.getOptions()).thenReturn(ToolCallingChatOptions.builder().build());
+ when(mockChatModel.call(any(Prompt.class))).thenReturn(chatResponse);
+ }
+
+ /**
+ * A name that also appears in {@code toolCallbacks} must be registered
once: Spring AI 2.0 rejects a
+ * {@code ToolCallingChatOptions} carrying two tools with the same name.
+ */
+ @Test
+ void testToolNameAlreadyInConfiguredCallbacksIsNotRegisteredTwice() {
+ String response = template().requestBody("direct:named", "What is the
capital of France?", String.class);
+
+ assertThat(response).isEqualTo("mock answer");
+
assertThat(capturedToolNames()).containsExactlyInAnyOrder("getCapital",
"getCurrentDateTime");
+ }
+
+ @Test
+ void testToolNamesFromHeaderAreAdditive() {
+ String response = template().requestBodyAndHeader("direct:callbacks",
"What time is it?",
+ SpringAiChatConstants.TOOL_NAMES, "getCurrentDateTime",
String.class);
+
+ assertThat(response).isEqualTo("mock answer");
+
assertThat(capturedToolNames()).containsExactlyInAnyOrder("getCapital",
"getCurrentDateTime");
+ }
+
+ /**
+ * A tool bound in the Camel registry under a name of its own resolves
even when it is not among the callbacks
+ * otherwise available to the endpoint.
+ */
+ @Test
+ void testToolResolvedFromRegistryByBeanName() {
+ String response = template().requestBodyAndHeader("direct:callbacks",
"What is the weather?",
+ SpringAiChatConstants.TOOL_NAMES, "weatherTool", String.class);
+
+ assertThat(response).isEqualTo("mock answer");
+
assertThat(capturedToolNames()).containsExactlyInAnyOrder("getCapital",
"getCurrentDateTime", "getWeather");
+ }
+
+ @Test
+ void testUnknownToolNameFailsTheExchange() {
+ Exchange exchange = template().request("direct:plain", e -> {
+ e.getIn().setBody("Anything");
+ e.getIn().setHeader(SpringAiChatConstants.TOOL_NAMES,
"noSuchTool");
+ });
+
+
assertThat(exchange.getException()).isInstanceOf(IllegalArgumentException.class);
+
assertThat(exchange.getException().getMessage()).contains("noSuchTool");
+ }
+
+ /**
+ * The tool names carried by the {@link Prompt} the component handed to
the {@link ChatModel}.
+ */
+ private List<String> capturedToolNames() {
+ ArgumentCaptor<Prompt> promptCaptor =
ArgumentCaptor.forClass(Prompt.class);
+ verify(mockChatModel).call(promptCaptor.capture());
+
+ ChatOptions options = promptCaptor.getValue().getOptions();
+ assertThat(options).isInstanceOf(ToolCallingChatOptions.class);
+
+ return ((ToolCallingChatOptions) options).getToolCallbacks().stream()
+ .map(callback -> callback.getToolDefinition().name())
+ .toList();
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ context.getRegistry().bind("chatModel", mockChatModel);
+
+ ToolCallbackProvider provider =
MethodToolCallbackProvider.builder()
+ .toolObjects(new MyTools())
+ .build();
+ List<ToolCallback> callbacks =
Arrays.asList(provider.getToolCallbacks());
+ context.getRegistry().bind("myTools", callbacks);
+
+ // a ToolCallback that is not part of the myTools list, bound
under a registry name of its
+ // own, to cover the registry lookup fallback
+ ToolCallbackProvider extraProvider =
MethodToolCallbackProvider.builder()
+ .toolObjects(new ExtraTools())
+ .build();
+ context.getRegistry().bind("weatherTool",
extraProvider.getToolCallbacks()[0]);
+
+ from("direct:named")
+
.to("spring-ai-chat:named?chatModel=#chatModel&toolCallbacks=#myTools&toolNames=getCapital");
+
+ from("direct:callbacks")
+
.to("spring-ai-chat:callbacks?chatModel=#chatModel&toolCallbacks=#myTools");
+
+ from("direct:plain")
+ .to("spring-ai-chat:plain?chatModel=#chatModel");
+ }
+ };
+ }
+
+ public static class MyTools {
+
+ @Tool(description = "Get the current date and time")
+ public String getCurrentDateTime() {
+ return "2026-08-05T10:30:00Z";
+ }
+
+ @Tool(description = "Get the capital city of a country")
+ public String getCapital(String country) {
+ return "The capital of " + country + " is unknown";
+ }
+ }
+
+ public static class ExtraTools {
+
+ @Tool(description = "Get the weather of a city")
+ public String getWeather(String city) {
+ return "The weather in " + city + " is unknown";
+ }
+ }
+}
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatVectorMemoryIT.java
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatVectorMemoryIT.java
index 6f7205b289ef..b59d2d80b7f0 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatVectorMemoryIT.java
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatVectorMemoryIT.java
@@ -50,7 +50,7 @@ public class SpringAiChatVectorMemoryIT extends
OllamaTestSupport {
EmbeddingModel embeddingModel = OllamaEmbeddingModel.builder()
.ollamaApi(ollamaApi)
- .defaultOptions(OllamaEmbeddingOptions.builder()
+ .options(OllamaEmbeddingOptions.builder()
.model("embeddinggemma:300m")
.build())
.build();
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatVectorStoreIT.java
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatVectorStoreIT.java
index fa4604ecffeb..91b6b79faa44 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatVectorStoreIT.java
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatVectorStoreIT.java
@@ -51,7 +51,7 @@ public class SpringAiChatVectorStoreIT extends
OllamaTestSupport {
EmbeddingModel embeddingModel = OllamaEmbeddingModel.builder()
.ollamaApi(ollamaApi)
- .defaultOptions(OllamaEmbeddingOptions.builder()
+ .options(OllamaEmbeddingOptions.builder()
.model("embeddinggemma:300m")
.build())
.build();
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatWrappedFileIT.java
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatWrappedFileIT.java
index 636ca36f7d0b..880113cbb529 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatWrappedFileIT.java
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-chat/src/test/java/org/apache/camel/component/springai/chat/SpringAiChatWrappedFileIT.java
@@ -29,7 +29,8 @@ import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.ai.ollama.api.OllamaApi;
import org.springframework.ai.ollama.api.OllamaChatOptions;
-import org.springframework.retry.support.RetryTemplate;
+import org.springframework.core.retry.RetryPolicy;
+import org.springframework.core.retry.RetryTemplate;
import static org.assertj.core.api.Assertions.assertThat;
@@ -68,14 +69,13 @@ public class SpringAiChatWrappedFileIT extends
OllamaTestSupport {
= OllamaChatOptions.builder()
.model(modelName())
.temperature(0.3)
+ .disableThinking()
.build();
return OllamaChatModel.builder()
.ollamaApi(ollamaApi)
- .defaultOptions(ollamaOptions)
- .retryTemplate(RetryTemplate.builder()
- .maxAttempts(1)
- .build())
+ .options(ollamaOptions)
+ .retryTemplate(new
RetryTemplate(RetryPolicy.withMaxRetries(0)))
.build();
}
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/test/java/org/apache/camel/component/springai/embeddings/OllamaTestSupport.java
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/test/java/org/apache/camel/component/springai/embeddings/OllamaTestSupport.java
index a33509645c93..7957f6639aa4 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/test/java/org/apache/camel/component/springai/embeddings/OllamaTestSupport.java
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/test/java/org/apache/camel/component/springai/embeddings/OllamaTestSupport.java
@@ -58,7 +58,7 @@ public class OllamaTestSupport extends CamelTestSupport {
EmbeddingModel embeddingModel = OllamaEmbeddingModel.builder()
.ollamaApi(ollamaApi)
- .defaultOptions(ollamaOptions)
+ .options(ollamaOptions)
.build();
return embeddingModel;
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 3f217b14517b..37db71b3c69a 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
@@ -90,7 +90,7 @@ public class SpringAiImageProducer extends DefaultProducer {
// Headers override configuration
Integer n = getOption(exchange, SpringAiImageHeaders.N, Integer.class,
config.getN());
if (n != null) {
- builder.N(n);
+ builder.n(n);
}
Integer width = getOption(exchange, SpringAiImageHeaders.WIDTH,
Integer.class, config.getWidth());
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/test/java/org/apache/camel/component/springai/image/SpringAiImageOllamaIT.java
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/test/java/org/apache/camel/component/springai/image/SpringAiImageOllamaIT.java
index ed5c1ff8df7a..55ed8c9d7a13 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/test/java/org/apache/camel/component/springai/image/SpringAiImageOllamaIT.java
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-image/src/test/java/org/apache/camel/component/springai/image/SpringAiImageOllamaIT.java
@@ -20,8 +20,10 @@ import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
-import java.util.List;
+import com.openai.client.OpenAIClient;
+import com.openai.client.OpenAIClientImpl;
+import com.openai.core.ClientOptions;
import org.apache.camel.Exchange;
import org.apache.camel.builder.RouteBuilder;
import org.apache.camel.test.infra.ollama.services.OllamaService;
@@ -38,11 +40,7 @@ import org.springframework.ai.image.Image;
import org.springframework.ai.image.ImageModel;
import org.springframework.ai.openai.OpenAiImageModel;
import org.springframework.ai.openai.OpenAiImageOptions;
-import org.springframework.ai.openai.api.OpenAiImageApi;
-import org.springframework.http.MediaType;
-import
org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
-import org.springframework.retry.support.RetryTemplate;
-import org.springframework.web.client.RestClient;
+import org.springframework.ai.openai.http.okhttp.SpringAiOpenAiHttpClient;
import static org.assertj.core.api.Assertions.assertThat;
@@ -52,8 +50,8 @@ import static org.assertj.core.api.Assertions.assertThat;
* Uses x/flux2-klein:4b model running on Ollama, accessed via OpenAI base URL
override. The model must be pre-pulled in
* Ollama.
*
- * Ollama returns application/x-ndjson content type, so a custom
RestClient.Builder is used to register a Jackson
- * converter that supports this media type.
+ * Since Spring AI 2.0 the OpenAI models are built on the official openai-java
SDK, so the Ollama endpoint is configured
+ * through the SDK client options rather than through a Spring RestClient.
*/
@DisabledIfSystemProperty(named = "ci.env.name", matches = ".*",
disabledReason = "Disabled unless running in CI")
@Timeout(180)
@@ -82,33 +80,27 @@ public class SpringAiImageOllamaIT extends CamelTestSupport
{
LOG.info("*******************************************************************************");
LOG.info("Setting up ImageModel with Ollama at {} using model '{}'",
OLLAMA.baseUrl(), IMAGE_MODEL_NAME);
- // Ollama returns application/x-ndjson which Spring's default
RestClient doesn't handle.
- // Add a Jackson converter that supports this media type.
- MappingJackson2HttpMessageConverter ndjsonConverter = new
MappingJackson2HttpMessageConverter();
- ndjsonConverter.setSupportedMediaTypes(
- List.of(MediaType.APPLICATION_JSON,
MediaType.valueOf("application/x-ndjson")));
-
- RestClient.Builder restClientBuilder = RestClient.builder()
- .messageConverters(converters -> converters.add(0,
ndjsonConverter));
-
- OpenAiImageApi imageApi = OpenAiImageApi.builder()
- .baseUrl(OLLAMA.baseUrl())
- .apiKey("unused")
- .restClientBuilder(restClientBuilder)
- .build();
+ // Spring AI 2.0 builds the OpenAI models on the official openai-java
SDK, so the endpoint and the
+ // credentials are configured on the SDK client rather than on a
Spring RestClient.
+ // maxRetries(0) disables the SDK default exponential backoff retries
in ITs: a failing Ollama call
+ // would otherwise block CI for many minutes (see CAMEL-23215).
+ OpenAIClient openAiClient = new OpenAIClientImpl(
+ ClientOptions.builder()
+ .httpClient(SpringAiOpenAiHttpClient.builder().build())
+ .baseUrl(OLLAMA.baseUrlV1())
+ .apiKey("unused")
+ .maxRetries(0)
+ .build());
// Only set the model name here; width/height are configured on the
Camel endpoint or via headers
OpenAiImageOptions defaultOptions = OpenAiImageOptions.builder()
.model(IMAGE_MODEL_NAME)
.build();
- // Disable Spring AI default exponential backoff retries in ITs: a
failing Ollama call would
- // otherwise block CI for many minutes (see CAMEL-23215).
- RetryTemplate noRetryTemplate = RetryTemplate.builder()
- .maxAttempts(1)
+ imageModel = OpenAiImageModel.builder()
+ .openAiClient(openAiClient)
+ .options(defaultOptions)
.build();
-
- imageModel = new OpenAiImageModel(imageApi, defaultOptions,
noRetryTemplate);
LOG.info("ImageModel initialized successfully");
}
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-vector-store/src/test/java/org/apache/camel/component/springai/vectorstore/QdrantVectorStoreIT.java
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-vector-store/src/test/java/org/apache/camel/component/springai/vectorstore/QdrantVectorStoreIT.java
index 194b3bfdea11..6ec380cc6d38 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-vector-store/src/test/java/org/apache/camel/component/springai/vectorstore/QdrantVectorStoreIT.java
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-vector-store/src/test/java/org/apache/camel/component/springai/vectorstore/QdrantVectorStoreIT.java
@@ -95,7 +95,7 @@ public class QdrantVectorStoreIT extends CamelTestSupport {
embeddingModel = OllamaEmbeddingModel.builder()
.ollamaApi(ollamaApi)
- .defaultOptions(ollamaOptions)
+ .options(ollamaOptions)
.build();
// Create Qdrant client
diff --git a/components/camel-spring-parent/camel-spring-ai/test-execution.md
b/components/camel-spring-parent/camel-spring-ai/test-execution.md
new file mode 100644
index 000000000000..b1d5bb22994c
--- /dev/null
+++ b/components/camel-spring-parent/camel-spring-ai/test-execution.md
@@ -0,0 +1,101 @@
+# camel-spring-ai test execution
+
+## Unit tests
+
+```bash
+mvn test
+```
+
+19 tests across the four modules. No Ollama, no Docker.
+
+## Integration tests
+
+The ITs drive a real LLM. `OllamaServiceFactory` uses a local Ollama when
+`http://localhost:11434` answers and falls back to a testcontainer otherwise,
so a running local
+Ollama is picked up automatically. The ITs are skipped when `ci.env.name` is
set.
+
+### Recommended setup
+
+No single model covers every IT, so the suite is run in two passes: a small
tool-calling model
+for the bulk of it, and a small vision model for the multimodal IT.
+
+```bash
+ollama pull granite4:3b # chat and tool calling 2.1 GB
+ollama pull embeddinggemma:300m # embeddings and vector store 621 MB
+ollama pull qwen3-vl:2b-instruct # multimodal 1.9 GB
+ollama pull x/flux2-klein:4b # image generation 5.7 GB
+```
+
+Main pass — everything except the multimodal IT:
+
+```bash
+mvn verify -Dollama.model=granite4:3b
-Dollama.embedding.model=embeddinggemma:300m
+```
+
+Multimodal pass:
+
+```bash
+mvn verify -pl camel-spring-ai-chat -Dit.test=SpringAiChatMultimodalIT \
+ -Dollama.model=qwen3-vl:2b-instruct
+```
+
+Together these are green: 91 chat tests (5 skipped), 7 embeddings, 16 vector
store and 4 image.
+
+### Why two models
+
+`granite4:3b` is the `camel-test-infra-ollama` default. It is the smallest
model here that passes
+every tool-calling IT — `SpringAiChatToolsIntegrationIT`,
`SpringAiChatToolContextIT`,
+`SpringAiChatToolBeanDiscoveryIT`, `SpringAiChatMcpIT` and
`SpringAiChatMcpSseIT` — which is what
+most of this component's surface is about.
+
+It has no vision support, so `SpringAiChatMultimodalIT` fails against it with
+`Multimodal data provided, but model does not support multimodal requests`.
`qwen3-vl:2b-instruct`
+covers that in 1.9 GB. Use the `-instruct` tag, not `-thinking`.
+
+Keeping the two roles in separate small models is cheaper than reaching for
one large model that
+does both: the pair is 4.0 GB combined and each is fast.
+
+### Models pinned inside tests
+
+Some ITs hardcode a model rather than reading a property. They fail with a
`404 ... not found`
+from Ollama if it has not been pulled:
+
+| Model | Used by |
+|---|---|
+| `embeddinggemma:300m` | `SpringAiChatVectorStoreIT`,
`SpringAiChatVectorMemoryIT`, `QdrantVectorStoreIT` |
+| `x/flux2-klein:4b` | `SpringAiImageOllamaIT` |
+
+`ollama.embedding.model` only drives `SpringAiEmbeddingsIT`.
+
+### Extra requirements
+
+- `SpringAiChatMcpIT` needs Node.js and `npx` on the path — it starts the MCP
filesystem server
+ over stdio.
+- `SpringAiChatMcpSseIT` and `QdrantVectorStoreIT` need Docker
(testcontainers).
+- `SpringAiChatWrappedFileIT` is `@Disabled`: it wants a model that handles
images and PDFs.
+
+### Reasoning models
+
+`OllamaTestSupport` calls `OllamaChatOptions.disableThinking()`, which is new
in Spring AI 2.0.
+Without it a reasoning model spends its whole token budget on the thinking
channel and answers
+with empty content, so the assertions fail or the run hangs on an unbounded
generation. Ollama
+accepts `think=false` on models without a thinking mode too, so this is safe
for every model —
+the granite4:3b result is identical with and without it.
+
+### Other models tried
+
+Measured on `camel-spring-ai-chat` (91 tests, 5 skipped) on Apple Silicon.
Ollama is MLX-backed on
+Apple Silicon whether or not the tag says `-mlx`, so `granite4:3b` already
gets that benefit.
+
+| Model | Size | Time | Result |
+|---|---:|---:|---|
+| `granite4:3b` | 2.1 GB | 3:13 | recommended; only `SpringAiChatMultimodalIT`
fails, for lack of vision |
+| `gemma4:e2b-mlx` | 6.5 GB | 2:36 | fastest, and passes the multimodal IT
in-process — but 4 failures: `ToolContext` and MCP tool calls come back with
empty content |
+| `gemma4:12b-mlx` | 7.7 GB | 10:06 | everything it ran passed, including the
tool calls `e2b` got wrong and the multimodal IT — but it exceeds the 600s
`camel.failsafe.forkTimeout`, so the fork is killed and the build fails |
+| `qwen3.5:2b-mlx` | 3.1 GB | — | unusable here: hangs even with thinking
disabled |
+
+Gemma 4 is the only family tried that has both vision and tool calling, so it
is the one that
+could in principle replace the pair above with a single model. Neither size
works out: `e2b` is
+fast but its tool calling is not reliable enough for these ITs, and `12b` is
capable enough but
+too slow for the harness — raising `camel.failsafe.forkTimeout` past its 600s
default would be a
+prerequisite, and even then a 10 minute chat module is a poor trade against
granite's 3 minutes.
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
index c60dc47f0a8f..a6b8fb066326 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
@@ -1579,3 +1579,43 @@ read once while the `CamelContext` initializes and
cannot be changed afterwards.
`org.apache.camel.spi.StartupStepRecorder` gained the default methods
`isRuntimeEnabled()` and `setRuntimeEnabled(boolean)`. Both have no-op
defaults, so
existing implementations continue to compile and behave as before.
+
+=== camel-spring-ai - upgraded to Spring AI 2.0
+
+The `camel-spring-ai-chat`, `camel-spring-ai-embeddings`,
`camel-spring-ai-image` and
+`camel-spring-ai-vector-store` components now build on Spring AI 2.0 instead
of Spring AI 1.1.
+Spring AI 1.1 targets Spring Boot 3.5 and Spring Framework 6, while Camel is
already on Spring Boot
+4.1 and Spring Framework 7, which is the baseline Spring AI 2.0 was designed
for. The upgrade also
+aligns the MCP Java SDK that `camel-spring-ai-chat` pulls in transitively
(previously 0.18.2) with
+the 2.0.0 version already used by `camel-mcp-server` and `camel-openai`.
+
+Applications that configure Spring AI themselves must migrate along with it.
The full list of
+changes is in the
+https://docs.spring.io/spring-ai/reference/upgrade-notes.html[Spring AI
upgrade notes]; the ones
+most likely to affect a Camel route are:
+
+* Spring AI moved from Jackson 2 to Jackson 3 (`tools.jackson`).
+* Configuration property keys lost the `options` segment, for example
+ `spring.ai.openai.chat.options.model` is now `spring.ai.openai.chat.model`.
+* Spring AI no longer applies a default `temperature` of `0.7`; each
provider's own default is used
+ unless the endpoint or a header sets one.
+* `spring-ai-advisors-vector-store` was renamed to
`spring-ai-vector-store-advisor`, and the
+ `spring-ai-azure-openai` and `spring-ai-openai-sdk` modules were folded into
`spring-ai-openai`.
+
+==== camel-spring-ai-chat - toolNames resolution
+
+Spring AI 2.0 removed `ChatClient.ChatClientRequestSpec.toolNames(String...)`
together with the
+Spring bean based `SpringBeanToolCallbackResolver`, so the `toolNames`
endpoint option and the
+`CamelSpringAiChatToolNames` header are now resolved by the component itself.
Each name is looked up,
+in order, against a `ToolCallbackResolver` bound in the registry, then against
the tools discovered
+via `tags` and configured via `toolCallbacks`, and finally against a
`ToolCallback` bound in the
+registry under that name. A name that resolves nowhere now fails the exchange
with an
+`IllegalArgumentException` listing the available tools, rather than being
silently ignored.
+
+Because Spring AI 2.0 rejects a `ToolCallingChatOptions` that carries two
tools with the same name,
+tools reachable through more than one source (tags, `toolCallbacks`,
`toolNames`) are now registered
+only once.
+
+In a Spring Boot application, Spring AI's auto-configured
`ToolCallbackResolver` is built from the
+`ToolCallback` and `ToolCallbackProvider` beans in the context. A bare `@Bean
Function<...>` is no
+longer resolvable by bean name; expose such tools as `ToolCallback` or
`ToolCallbackProvider` beans.
diff --git
a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SpringAiChatEndpointBuilderFactory.java
b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SpringAiChatEndpointBuilderFactory.java
index 78c19c567061..ed902853d765 100644
---
a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SpringAiChatEndpointBuilderFactory.java
+++
b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/SpringAiChatEndpointBuilderFactory.java
@@ -174,9 +174,12 @@ public interface SpringAiChatEndpointBuilderFactory {
return this;
}
/**
- * Comma-separated tool names for selecting tools by name via Spring
- * AI's ToolCallbackResolver. This enables selecting Spring Tool
- * annotated beans or any registered ToolCallback by name.
+ * Comma-separated tool names for selecting tools by name. Names are
+ * resolved against a ToolCallbackResolver bound in the registry
(Spring
+ * Boot applications get Spring AI's auto-configured one), then against
+ * the tools discovered via tags and configured via toolCallbacks, and
+ * finally against a ToolCallback bound in the registry under that
name.
+ * An unresolvable name fails the exchange.
*
* The option is a: <code>java.lang.String</code> type.
*
diff --git a/parent/pom.xml b/parent/pom.xml
index d10f812f6c4d..9b3ad13fb227 100644
--- a/parent/pom.xml
+++ b/parent/pom.xml
@@ -483,7 +483,7 @@
<solr-version>10.0.0</solr-version>
<splunk-version>1.9.5_1</splunk-version>
<spock-version>2.4-groovy-5.0</spock-version>
- <spring-ai-version>1.1.7</spring-ai-version>
+ <spring-ai-version>2.0.0</spring-ai-version>
<spring-cloud-config-version>5.0.4</spring-cloud-config-version>
<spring-batch-version>6.0.4</spring-batch-version>
<spring-boot-version>4.1.0</spring-boot-version>