diff --git a/contrib/spring-ai/pom.xml b/contrib/spring-ai/pom.xml index 2a6b1143b..c665e289f 100644 --- a/contrib/spring-ai/pom.xml +++ b/contrib/spring-ai/pom.xml @@ -137,6 +137,16 @@ spring-ai-ollama test + + org.springframework.ai + spring-ai-autoconfigure-model-openai + test + + + org.springframework.ai + spring-ai-autoconfigure-model-tool + test + diff --git a/contrib/spring-ai/src/main/java/com/google/adk/models/springai/autoconfigure/SpringAIAutoConfiguration.java b/contrib/spring-ai/src/main/java/com/google/adk/models/springai/autoconfigure/SpringAIAutoConfiguration.java index 7a312ca88..8e3ccbe09 100644 --- a/contrib/spring-ai/src/main/java/com/google/adk/models/springai/autoconfigure/SpringAIAutoConfiguration.java +++ b/contrib/spring-ai/src/main/java/com/google/adk/models/springai/autoconfigure/SpringAIAutoConfiguration.java @@ -24,20 +24,24 @@ import org.springframework.ai.chat.model.StreamingChatModel; import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.AutoConfigureOrder; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; +import org.springframework.core.Ordered; /** * Auto-configuration for Spring AI integration with ADK. * - *

This auto-configuration automatically creates SpringAI beans when Spring AI ChatModel beans - * are available in the application context. It supports both regular ChatModel and - * StreamingChatModel instances. + *

This auto-configuration automatically creates SpringAI beans when a Spring AI ChatModel bean + * is available in the application context as a single autowire candidate (or is marked + * {@code @Primary}). It supports both regular ChatModel and StreamingChatModel instances. When + * several model beans are present without a primary, the auto-configuration backs off and the + * application should define its own beans. * *

The auto-configuration can be disabled by setting: * @@ -59,6 +63,9 @@ * */ @AutoConfiguration +// Sorts after provider auto-configurations; configs ordered after this class need LOWEST_PRECEDENCE +// too. +@AutoConfigureOrder(Ordered.LOWEST_PRECEDENCE) @ConditionalOnClass({SpringAI.class, ChatModel.class}) @ConditionalOnProperty( prefix = "adk.spring-ai.auto-configuration", @@ -71,7 +78,8 @@ public class SpringAIAutoConfiguration { private static final Logger logger = LoggerFactory.getLogger(SpringAIAutoConfiguration.class); /** - * Creates a SpringAI bean when both ChatModel and StreamingChatModel are available. + * Creates a SpringAI bean when a single-candidate ChatModel is available. Every ChatModel is also + * a StreamingChatModel, so one bean serves both paths. * * @param chatModel the Spring AI ChatModel * @param streamingChatModel the Spring AI StreamingChatModel @@ -81,7 +89,7 @@ public class SpringAIAutoConfiguration { @Bean @Primary @ConditionalOnMissingBean(SpringAI.class) - @ConditionalOnBean({ChatModel.class, StreamingChatModel.class}) + @ConditionalOnSingleCandidate(ChatModel.class) public SpringAI springAIWithBothModels( ChatModel chatModel, StreamingChatModel streamingChatModel, SpringAIProperties properties) { @@ -95,7 +103,7 @@ public SpringAI springAIWithBothModels( } /** - * Creates a SpringAI bean when only ChatModel is available. + * Creates a SpringAI bean when only a single-candidate ChatModel is available. * * @param chatModel the Spring AI ChatModel * @param properties the ADK Spring AI properties @@ -103,7 +111,7 @@ public SpringAI springAIWithBothModels( */ @Bean @ConditionalOnMissingBean(SpringAI.class) - @ConditionalOnBean(ChatModel.class) + @ConditionalOnSingleCandidate(ChatModel.class) public SpringAI springAIWithChatModel(ChatModel chatModel, SpringAIProperties properties) { String modelName = determineModelName(chatModel, properties); @@ -114,7 +122,7 @@ public SpringAI springAIWithChatModel(ChatModel chatModel, SpringAIProperties pr } /** - * Creates a SpringAI bean when only StreamingChatModel is available. + * Creates a SpringAI bean when only a single-candidate StreamingChatModel is available. * * @param streamingChatModel the Spring AI StreamingChatModel * @param properties the ADK Spring AI properties @@ -122,7 +130,7 @@ public SpringAI springAIWithChatModel(ChatModel chatModel, SpringAIProperties pr */ @Bean @ConditionalOnMissingBean({SpringAI.class, ChatModel.class}) - @ConditionalOnBean(StreamingChatModel.class) + @ConditionalOnSingleCandidate(StreamingChatModel.class) public SpringAI springAIWithStreamingModel( StreamingChatModel streamingChatModel, SpringAIProperties properties) { @@ -134,7 +142,7 @@ public SpringAI springAIWithStreamingModel( } /** - * Creates a SpringAIEmbedding bean when EmbeddingModel is available. + * Creates a SpringAIEmbedding bean when a single-candidate EmbeddingModel is available. * * @param embeddingModel the Spring AI EmbeddingModel * @param properties the ADK Spring AI properties @@ -142,7 +150,7 @@ public SpringAI springAIWithStreamingModel( */ @Bean @ConditionalOnMissingBean(SpringAIEmbedding.class) - @ConditionalOnBean(EmbeddingModel.class) + @ConditionalOnSingleCandidate(EmbeddingModel.class) public SpringAIEmbedding springAIEmbedding( EmbeddingModel embeddingModel, SpringAIProperties properties) { diff --git a/contrib/spring-ai/src/test/java/com/google/adk/models/springai/autoconfigure/SpringAIAutoConfigurationOrderingTest.java b/contrib/spring-ai/src/test/java/com/google/adk/models/springai/autoconfigure/SpringAIAutoConfigurationOrderingTest.java new file mode 100644 index 000000000..d1dbd3d59 --- /dev/null +++ b/contrib/spring-ai/src/test/java/com/google/adk/models/springai/autoconfigure/SpringAIAutoConfigurationOrderingTest.java @@ -0,0 +1,55 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed 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 com.google.adk.models.springai.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.adk.models.springai.SpringAI; +import com.google.adk.models.springai.SpringAIEmbedding; +import org.junit.jupiter.api.Test; +import org.springframework.ai.model.openai.autoconfigure.OpenAiChatAutoConfiguration; +import org.springframework.ai.model.openai.autoconfigure.OpenAiEmbeddingAutoConfiguration; +import org.springframework.ai.model.tool.autoconfigure.ToolCallingAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +/** + * SpringAIAutoConfiguration sorts after provider auto-configurations via @AutoConfigureOrder, so + * its order-sensitive conditions can see the provider beans. + */ +class SpringAIAutoConfigurationOrderingTest { + + // ToolCallingAutoConfiguration provides the ToolCallingManager OpenAiChatAutoConfiguration needs. + private final ApplicationContextRunner providerRunner = + new ApplicationContextRunner() + .withPropertyValues("spring.ai.openai.api-key=dummy") + .withConfiguration( + AutoConfigurations.of( + SpringAIAutoConfiguration.class, + ToolCallingAutoConfiguration.class, + OpenAiChatAutoConfiguration.class, + OpenAiEmbeddingAutoConfiguration.class)); + + @Test + void registersSpringAI_whenProviderAutoConfigurationsAreProcessedFirst() { + providerRunner.run(context -> assertThat(context).hasSingleBean(SpringAI.class)); + } + + @Test + void registersSpringAIEmbedding_whenEmbeddingAutoConfigurationIsProcessedFirst() { + providerRunner.run(context -> assertThat(context).hasSingleBean(SpringAIEmbedding.class)); + } +} diff --git a/contrib/spring-ai/src/test/java/com/google/adk/models/springai/autoconfigure/SpringAIAutoConfigurationTest.java b/contrib/spring-ai/src/test/java/com/google/adk/models/springai/autoconfigure/SpringAIAutoConfigurationTest.java index 7c55b8d68..7854d1a78 100644 --- a/contrib/spring-ai/src/test/java/com/google/adk/models/springai/autoconfigure/SpringAIAutoConfigurationTest.java +++ b/contrib/spring-ai/src/test/java/com/google/adk/models/springai/autoconfigure/SpringAIAutoConfigurationTest.java @@ -16,8 +16,10 @@ package com.google.adk.models.springai.autoconfigure; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; import com.google.adk.models.springai.SpringAI; +import com.google.adk.models.springai.SpringAIEmbedding; import com.google.adk.models.springai.properties.SpringAIProperties; import org.junit.jupiter.api.Test; import org.springframework.ai.chat.messages.AssistantMessage; @@ -25,6 +27,7 @@ import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; import org.springframework.ai.chat.model.StreamingChatModel; +import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; @@ -164,6 +167,44 @@ void testDefaultConfiguration() { }); } + @Test + void testBacksOffWithMultipleChatModelBeans() { + contextRunner + .withBean("chatModelOne", ChatModel.class, () -> mock(ChatModel.class)) + .withBean("chatModelTwo", ChatModel.class, () -> mock(ChatModel.class)) + .run(context -> assertThat(context).doesNotHaveBean(SpringAI.class)); + } + + @Test + void testBacksOffWithMultipleStreamingChatModelBeans() { + contextRunner + .withBean( + "streamingModelOne", StreamingChatModel.class, () -> mock(StreamingChatModel.class)) + .withBean( + "streamingModelTwo", StreamingChatModel.class, () -> mock(StreamingChatModel.class)) + .run(context -> assertThat(context).doesNotHaveBean(SpringAI.class)); + } + + @Test + void testBacksOffWithMultipleEmbeddingModelBeans() { + contextRunner + .withBean("embeddingModelOne", EmbeddingModel.class, () -> mock(EmbeddingModel.class)) + .withBean("embeddingModelTwo", EmbeddingModel.class, () -> mock(EmbeddingModel.class)) + .run(context -> assertThat(context).doesNotHaveBean(SpringAIEmbedding.class)); + } + + @Test + void testRegistersSpringAIWithPrimaryChatModelBean() { + contextRunner + .withBean( + "primaryChatModel", + ChatModel.class, + () -> mock(ChatModel.class), + beanDefinition -> beanDefinition.setPrimary(true)) + .withBean("chatModelTwo", ChatModel.class, () -> mock(ChatModel.class)) + .run(context -> assertThat(context).hasSingleBean(SpringAI.class)); + } + @Configuration static class TestConfigurationWithBothModels { @Bean