From 0d4bc043f28aff8024cbf0ee70f5baaf4e203651 Mon Sep 17 00:00:00 2001 From: Igor Dayen Date: Thu, 23 Jul 2026 11:11:12 -0400 Subject: [PATCH 1/2] Fix model binding broken by Spring AI 2.0 options merge removal --- .../anthropic/AnthropicModelBindingTest.kt | 80 +++++++++++++++++++ .../springai/ChatClientLlmOperations.kt | 6 +- .../support/springai/SpringAiLlmService.kt | 10 ++- .../StreamingChatClientOperations.kt | 4 +- .../support/ChatClientLlmOperationsTest.kt | 23 ++++++ .../springai/SpringAiLlmServiceTest.kt | 44 +++++++++- .../anthropic/LlmAnthropicCachingIT.java | 2 +- 7 files changed, 157 insertions(+), 12 deletions(-) create mode 100644 embabel-agent-anthropic/src/test/kotlin/com/embabel/agent/anthropic/AnthropicModelBindingTest.kt diff --git a/embabel-agent-anthropic/src/test/kotlin/com/embabel/agent/anthropic/AnthropicModelBindingTest.kt b/embabel-agent-anthropic/src/test/kotlin/com/embabel/agent/anthropic/AnthropicModelBindingTest.kt new file mode 100644 index 000000000..79809f900 --- /dev/null +++ b/embabel-agent-anthropic/src/test/kotlin/com/embabel/agent/anthropic/AnthropicModelBindingTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * 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.embabel.agent.anthropic + +import com.embabel.agent.spi.support.springai.SpringAiLlmService +import com.embabel.common.ai.model.LlmOptions +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.ai.anthropic.AnthropicChatOptions + +/** + * Regression for the Spring AI 2.0 model-binding bug affecting Anthropic: + * [AnthropicOptionsConverter] never calls .model(), so its [AnthropicChatOptions] carry + * DEFAULT_MODEL ("claude-haiku-4-5"). Spring AI 2.0 no longer merges the ChatModel bean's + * configured model into per-request options, so haiku would go on the wire silently. + * [SpringAiLlmService.buildChatOptions] now stamps the service name explicitly. + */ +class AnthropicModelBindingTest { + + @Test + fun `buildChatOptions stamps configured model not AnthropicChatOptions haiku default`() { + val service = SpringAiLlmService( + name = "claude-sonnet-4-5", + provider = "Anthropic", + chatModel = mockk(relaxed = true), + optionsConverter = AnthropicOptionsConverter, + ) + + val result = service.buildChatOptions(LlmOptions()) + + assertThat(result).isInstanceOf(AnthropicChatOptions::class.java) + assertThat(result.model).isEqualTo("claude-sonnet-4-5") + assertThat(result.model).isNotEqualTo(AnthropicChatOptions.DEFAULT_MODEL) + } + + @Test + fun `buildChatOptions preserves AnthropicChatOptions fields alongside model`() { + val service = SpringAiLlmService( + name = "claude-sonnet-4-5", + provider = "Anthropic", + chatModel = mockk(relaxed = true), + optionsConverter = AnthropicOptionsConverter, + ) + + val result = service.buildChatOptions(LlmOptions().withMaxTokens(500)) + + assertThat(result).isInstanceOf(AnthropicChatOptions::class.java) + assertThat(result.model).isEqualTo("claude-sonnet-4-5") + assertThat(result.maxTokens).isEqualTo(500) + } + + @Test + fun `buildChatOptions result type is AnthropicChatOptions not generic fallback`() { + val service = SpringAiLlmService( + name = "claude-haiku-4-5", + provider = "Anthropic", + chatModel = mockk(relaxed = true), + optionsConverter = AnthropicOptionsConverter, + ) + + val result = service.buildChatOptions(LlmOptions()) + + assertThat(result).isInstanceOf(AnthropicChatOptions::class.java) + assertThat(result.model).isEqualTo("claude-haiku-4-5") + } +} diff --git a/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/springai/ChatClientLlmOperations.kt b/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/springai/ChatClientLlmOperations.kt index 4b0b59e3c..69bb511a3 100644 --- a/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/springai/ChatClientLlmOperations.kt +++ b/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/springai/ChatClientLlmOperations.kt @@ -189,7 +189,7 @@ internal class ChatClientLlmOperations( ): LlmMessageSender { if (llmRequestEvent != null) { val springAiLlm = requireSpringAiLlm(llm) - val chatOptions = springAiLlm.optionsConverter.convertOptions(options) + val chatOptions = springAiLlm.buildChatOptions(options) val instrumentedModel = InstrumentedChatModel(springAiLlm.chatModel, llmRequestEvent) return SpringAiLlmMessageSender( chatModel = instrumentedModel, @@ -348,7 +348,7 @@ internal class ChatClientLlmOperations( val schemaFormat = converter?.getFormat() - val chatOptions = requireSpringAiLlm(llm).optionsConverter.convertOptions(interaction.llm) + val chatOptions = requireSpringAiLlm(llm).buildChatOptions(interaction.llm) val timeoutMillis = getTimeoutMillis(interaction.llm) val basePrompt = if (schemaFormat != null) { @@ -507,7 +507,7 @@ internal class ChatClientLlmOperations( // Get the complete format (examples + JSON schema) val schemaFormat = converter.getFormat() - val chatOptions = requireSpringAiLlm(llm).optionsConverter.convertOptions(interaction.llm) + val chatOptions = requireSpringAiLlm(llm).buildChatOptions(interaction.llm) val timeoutMillis = getTimeoutMillis(interaction.llm) val basePrompt = buildPromptWithMaybeReturnAndSchema( diff --git a/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/springai/SpringAiLlmService.kt b/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/springai/SpringAiLlmService.kt index 8f5fe2e41..cfa2b5417 100644 --- a/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/springai/SpringAiLlmService.kt +++ b/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/springai/SpringAiLlmService.kt @@ -28,6 +28,7 @@ import org.springframework.ai.chat.client.ChatClient import org.springframework.ai.chat.messages.UserMessage import org.springframework.ai.chat.model.ChatModel import org.springframework.ai.chat.model.ChatResponse +import org.springframework.ai.chat.prompt.ChatOptions import org.springframework.ai.chat.prompt.Prompt import reactor.core.publisher.Flux import java.time.Duration @@ -123,11 +124,13 @@ data class SpringAiLlmService @JvmOverloads constructor( */ override val model: ChatModel get() = chatModel + fun buildChatOptions(llmOptions: LlmOptions): ChatOptions = + optionsConverter.convertOptions(llmOptions).mutate().model(name).build() + override fun createMessageSender(options: LlmOptions): LlmMessageSender { - val chatOptions = optionsConverter.convertOptions(options) return SpringAiLlmMessageSender( chatModel = chatModel, - chatOptions = chatOptions, + chatOptions = buildChatOptions(options), toolResponseContentAdapter = toolResponseContentAdapter, nativeStructuredOutputConfigurer = nativeStructuredOutputConfigurer, nativeSupport = nativeSupport, @@ -136,9 +139,8 @@ data class SpringAiLlmService @JvmOverloads constructor( } override fun createMessageStreamer(options: LlmOptions): LlmMessageStreamer { - val chatOptions = optionsConverter.convertOptions(options) val chatClient = ChatClient.create(chatModel) - return SpringAiLlmMessageStreamer(chatClient, chatOptions) + return SpringAiLlmMessageStreamer(chatClient, buildChatOptions(options)) } override fun supportsStreaming(): Boolean = StreamingCapabilityVerifier.supportsStreaming(chatModel) diff --git a/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/springai/streaming/StreamingChatClientOperations.kt b/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/springai/streaming/StreamingChatClientOperations.kt index 3ada4bf91..a2d619e3d 100644 --- a/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/springai/streaming/StreamingChatClientOperations.kt +++ b/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/springai/streaming/StreamingChatClientOperations.kt @@ -174,7 +174,7 @@ internal class StreamingChatClientOperations( val userMessages = messages.filterIsInstance() validateUserInput(userMessages, interaction, llmRequestEvent?.agentProcess?.blackboard) - val chatOptions = requireSpringAiLlm(llm).optionsConverter.convertOptions(interaction.llm) + val chatOptions = requireSpringAiLlm(llm).buildChatOptions(interaction.llm) // Resolve tool groups and decorate tools val tools = chatClientLlmOperations.resolveAndDecorateTools(interaction, agentProcess, action) @@ -346,7 +346,7 @@ internal class StreamingChatClientOperations( // Chat Client val chatClient = chatClientLlmOperations.createChatClient(llm) // Chat Options, additional potential option "streaming" - val chatOptions = requireSpringAiLlm(llm).optionsConverter.convertOptions(interaction.llm) + val chatOptions = requireSpringAiLlm(llm).buildChatOptions(interaction.llm) // Spring AI 2.0's StreamingJacksonOutputConverter requires T : Any; // erase O via Class for the construction, cast result back at use sites. diff --git a/embabel-agent-api/src/test/kotlin/com/embabel/agent/spi/support/ChatClientLlmOperationsTest.kt b/embabel-agent-api/src/test/kotlin/com/embabel/agent/spi/support/ChatClientLlmOperationsTest.kt index 9d25b1a4a..a2b27f3f4 100644 --- a/embabel-agent-api/src/test/kotlin/com/embabel/agent/spi/support/ChatClientLlmOperationsTest.kt +++ b/embabel-agent-api/src/test/kotlin/com/embabel/agent/spi/support/ChatClientLlmOperationsTest.kt @@ -49,6 +49,7 @@ import io.mockk.mockk import io.mockk.slot import jakarta.validation.Validation import jakarta.validation.constraints.Pattern +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test @@ -1323,4 +1324,26 @@ class ChatClientLlmOperationsTest { } } + @Nested + inner class ModelBinding { + + @Test + fun `model name from SpringAiLlmService reaches ChatModel call`() { + val duke = Dog("Duke") + val fakeChatModel = FakeChatModel(jacksonObjectMapper().writeValueAsString(duke)) + val setup = createChatClientLlmOperations(fakeChatModel) + + setup.llmOperations.createObject( + messages = listOf(UserMessage("prompt")), + interaction = LlmInteraction(id = InteractionId("id"), llm = LlmOptions()), + outputClass = Dog::class.java, + action = SimpleTestAgent.actions.first(), + agentProcess = setup.mockAgentProcess, + ) + + assertThat(fakeChatModel.optionsPassed).isNotEmpty() + assertThat(fakeChatModel.optionsPassed[0].model).isEqualTo("fake") + } + } + } diff --git a/embabel-agent-api/src/test/kotlin/com/embabel/agent/spi/support/springai/SpringAiLlmServiceTest.kt b/embabel-agent-api/src/test/kotlin/com/embabel/agent/spi/support/springai/SpringAiLlmServiceTest.kt index 5eccb4de9..3959e1cf0 100644 --- a/embabel-agent-api/src/test/kotlin/com/embabel/agent/spi/support/springai/SpringAiLlmServiceTest.kt +++ b/embabel-agent-api/src/test/kotlin/com/embabel/agent/spi/support/springai/SpringAiLlmServiceTest.kt @@ -30,6 +30,7 @@ import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.springframework.ai.chat.model.ChatModel import org.springframework.ai.chat.prompt.ChatOptions +import org.springframework.ai.model.tool.ToolCallingChatOptions import java.time.LocalDate class SpringAiLlmServiceTest { @@ -249,6 +250,45 @@ class SpringAiLlmServiceTest { } } + @Nested + inner class BuildChatOptionsTests { + + @Test + fun `buildChatOptions stamps service name as model`() { + val service = SpringAiLlmService( + name = "my-model", + provider = "Provider", + chatModel = mockChatModel, + ) + assertThat(service.buildChatOptions(LlmOptions()).model).isEqualTo("my-model") + } + + @Test + fun `buildChatOptions overrides converter default model with service name`() { + val service = SpringAiLlmService( + name = "my-model", + provider = "Provider", + chatModel = mockChatModel, + ) + val result = service.buildChatOptions(LlmOptions()) + assertThat(result.model).isEqualTo("my-model") + assertThat(result.model).isNotEqualTo("some-default-model") + } + + @Test + fun `buildChatOptions preserves converter fields alongside model`() { + val service = SpringAiLlmService( + name = "my-model", + provider = "Provider", + chatModel = mockChatModel, + ) + val result = service.buildChatOptions(LlmOptions().withTemperature(0.5).withMaxTokens(100)) + assertThat(result.model).isEqualTo("my-model") + assertThat(result.temperature).isEqualTo(0.5) + assertThat(result.maxTokens).isEqualTo(100) + } + } + @Nested inner class CreateMessageSenderTests { @@ -272,7 +312,7 @@ class SpringAiLlmServiceTest { val customConverter = object : OptionsConverter { override fun convertOptions(options: LlmOptions): ChatOptions { converterCalled = true - return mockk() + return ToolCallingChatOptions.builder().build() } } val service = SpringAiLlmService( @@ -314,7 +354,7 @@ class SpringAiLlmServiceTest { val customConverter = object : OptionsConverter { override fun convertOptions(options: LlmOptions): ChatOptions { converterCalled = true - return mockk() + return ToolCallingChatOptions.builder().build() } } val service = SpringAiLlmService( diff --git a/embabel-agent-autoconfigure/models/embabel-agent-anthropic-autoconfigure/src/test/java/com/embabel/agent/config/models/anthropic/LlmAnthropicCachingIT.java b/embabel-agent-autoconfigure/models/embabel-agent-anthropic-autoconfigure/src/test/java/com/embabel/agent/config/models/anthropic/LlmAnthropicCachingIT.java index 79db9197e..511dede5f 100644 --- a/embabel-agent-autoconfigure/models/embabel-agent-anthropic-autoconfigure/src/test/java/com/embabel/agent/config/models/anthropic/LlmAnthropicCachingIT.java +++ b/embabel-agent-autoconfigure/models/embabel-agent-anthropic-autoconfigure/src/test/java/com/embabel/agent/config/models/anthropic/LlmAnthropicCachingIT.java @@ -308,7 +308,7 @@ public Integer calculateDewPoint(Integer temperatureFahrenheit, Integer relative @Test void testSystemPromptCaching() { - logger.info("Testing system prompt caching"); + logger.info("Testing system prompt caching with thinking enabled"); AnthropicCachingConfig cachingConfig = new AnthropicCachingConfig(); cachingConfig.setSystemPrompt(true); From de8ffe51962ff548e2e8908844770fca850e3036 Mon Sep 17 00:00:00 2001 From: Igor Dayen Date: Fri, 24 Jul 2026 21:55:51 -0400 Subject: [PATCH 2/2] OptionConverter with 2-arg function converOptions --- .../anthropic/AnthropicModelBindingTest.kt | 14 +++++++------- .../anthropic/AnthropicOptionsConverterTest.kt | 2 ++ .../support/springai/ChatClientLlmOperations.kt | 6 +++--- .../spi/support/springai/SpringAiLlmService.kt | 12 +++++++----- .../streaming/StreamingChatClientOperations.kt | 4 ++-- .../support/springai/SpringAiLlmServiceTest.kt | 14 +++++++------- .../deepseek/DeepSeekOptionsConverterTest.kt | 2 ++ .../GoogleGenAiOptionsConverterTest.kt | 2 ++ .../minimax/MiniMaxOptionsConverterTest.kt | 2 ++ .../models/ollama/OllamaOptionsConverterTest.kt | 2 ++ .../models/zai/ZaiOptionsConverterTest.kt | 2 ++ .../embabel/common/ai/model/OptionsConverter.kt | 17 ++++++++++++++++- .../test/models/optionsConverterTestUtils.kt | 2 ++ 13 files changed, 56 insertions(+), 25 deletions(-) diff --git a/embabel-agent-anthropic/src/test/kotlin/com/embabel/agent/anthropic/AnthropicModelBindingTest.kt b/embabel-agent-anthropic/src/test/kotlin/com/embabel/agent/anthropic/AnthropicModelBindingTest.kt index 79809f900..4bf120736 100644 --- a/embabel-agent-anthropic/src/test/kotlin/com/embabel/agent/anthropic/AnthropicModelBindingTest.kt +++ b/embabel-agent-anthropic/src/test/kotlin/com/embabel/agent/anthropic/AnthropicModelBindingTest.kt @@ -27,12 +27,12 @@ import org.springframework.ai.anthropic.AnthropicChatOptions * [AnthropicOptionsConverter] never calls .model(), so its [AnthropicChatOptions] carry * DEFAULT_MODEL ("claude-haiku-4-5"). Spring AI 2.0 no longer merges the ChatModel bean's * configured model into per-request options, so haiku would go on the wire silently. - * [SpringAiLlmService.buildChatOptions] now stamps the service name explicitly. + * [SpringAiLlmService.convertOptions] now stamps the service name explicitly. */ class AnthropicModelBindingTest { @Test - fun `buildChatOptions stamps configured model not AnthropicChatOptions haiku default`() { + fun `convertOptions stamps configured model not AnthropicChatOptions haiku default`() { val service = SpringAiLlmService( name = "claude-sonnet-4-5", provider = "Anthropic", @@ -40,7 +40,7 @@ class AnthropicModelBindingTest { optionsConverter = AnthropicOptionsConverter, ) - val result = service.buildChatOptions(LlmOptions()) + val result = service.convertOptions(LlmOptions()) assertThat(result).isInstanceOf(AnthropicChatOptions::class.java) assertThat(result.model).isEqualTo("claude-sonnet-4-5") @@ -48,7 +48,7 @@ class AnthropicModelBindingTest { } @Test - fun `buildChatOptions preserves AnthropicChatOptions fields alongside model`() { + fun `convertOptions preserves AnthropicChatOptions fields alongside model`() { val service = SpringAiLlmService( name = "claude-sonnet-4-5", provider = "Anthropic", @@ -56,7 +56,7 @@ class AnthropicModelBindingTest { optionsConverter = AnthropicOptionsConverter, ) - val result = service.buildChatOptions(LlmOptions().withMaxTokens(500)) + val result = service.convertOptions(LlmOptions().withMaxTokens(500)) assertThat(result).isInstanceOf(AnthropicChatOptions::class.java) assertThat(result.model).isEqualTo("claude-sonnet-4-5") @@ -64,7 +64,7 @@ class AnthropicModelBindingTest { } @Test - fun `buildChatOptions result type is AnthropicChatOptions not generic fallback`() { + fun `convertOptions result type is AnthropicChatOptions not generic fallback`() { val service = SpringAiLlmService( name = "claude-haiku-4-5", provider = "Anthropic", @@ -72,7 +72,7 @@ class AnthropicModelBindingTest { optionsConverter = AnthropicOptionsConverter, ) - val result = service.buildChatOptions(LlmOptions()) + val result = service.convertOptions(LlmOptions()) assertThat(result).isInstanceOf(AnthropicChatOptions::class.java) assertThat(result.model).isEqualTo("claude-haiku-4-5") diff --git a/embabel-agent-anthropic/src/test/kotlin/com/embabel/agent/anthropic/AnthropicOptionsConverterTest.kt b/embabel-agent-anthropic/src/test/kotlin/com/embabel/agent/anthropic/AnthropicOptionsConverterTest.kt index 5b15bfd00..e7da35d3a 100644 --- a/embabel-agent-anthropic/src/test/kotlin/com/embabel/agent/anthropic/AnthropicOptionsConverterTest.kt +++ b/embabel-agent-anthropic/src/test/kotlin/com/embabel/agent/anthropic/AnthropicOptionsConverterTest.kt @@ -28,6 +28,8 @@ import org.springframework.ai.anthropic.AnthropicCacheStrategy import org.springframework.ai.anthropic.AnthropicCacheTtl import org.springframework.ai.anthropic.AnthropicChatOptions +// Calls the deprecated 1-arg convertOptions() directly to verify field mapping in isolation. +// Model stamping is not tested here — it is covered by OptionsConverter.convertOptions(options, model). class AnthropicOptionsConverterTest : OptionsConverterTestSupport( optionsConverter = AnthropicOptionsConverter ) { diff --git a/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/springai/ChatClientLlmOperations.kt b/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/springai/ChatClientLlmOperations.kt index 69bb511a3..d5151f6ba 100644 --- a/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/springai/ChatClientLlmOperations.kt +++ b/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/springai/ChatClientLlmOperations.kt @@ -189,7 +189,7 @@ internal class ChatClientLlmOperations( ): LlmMessageSender { if (llmRequestEvent != null) { val springAiLlm = requireSpringAiLlm(llm) - val chatOptions = springAiLlm.buildChatOptions(options) + val chatOptions = springAiLlm.convertOptions(options) val instrumentedModel = InstrumentedChatModel(springAiLlm.chatModel, llmRequestEvent) return SpringAiLlmMessageSender( chatModel = instrumentedModel, @@ -348,7 +348,7 @@ internal class ChatClientLlmOperations( val schemaFormat = converter?.getFormat() - val chatOptions = requireSpringAiLlm(llm).buildChatOptions(interaction.llm) + val chatOptions = requireSpringAiLlm(llm).convertOptions(interaction.llm) val timeoutMillis = getTimeoutMillis(interaction.llm) val basePrompt = if (schemaFormat != null) { @@ -507,7 +507,7 @@ internal class ChatClientLlmOperations( // Get the complete format (examples + JSON schema) val schemaFormat = converter.getFormat() - val chatOptions = requireSpringAiLlm(llm).buildChatOptions(interaction.llm) + val chatOptions = requireSpringAiLlm(llm).convertOptions(interaction.llm) val timeoutMillis = getTimeoutMillis(interaction.llm) val basePrompt = buildPromptWithMaybeReturnAndSchema( diff --git a/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/springai/SpringAiLlmService.kt b/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/springai/SpringAiLlmService.kt index cfa2b5417..3444f0539 100644 --- a/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/springai/SpringAiLlmService.kt +++ b/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/springai/SpringAiLlmService.kt @@ -86,7 +86,9 @@ private object StreamingCapabilityVerifier { * @param name Name of the LLM * @param provider Name of the provider (e.g., "OpenAI", "Anthropic") * @param chatModel The Spring AI ChatModel to use for LLM calls - * @param optionsConverter Function to convert [LlmOptions] to Spring AI ChatOptions + * @param optionsConverter Function to convert [LlmOptions] to Spring AI ChatOptions. + * Do not call [OptionsConverter.convertOptions] directly — use [SpringAiLlmService.convertOptions] + * which also stamps the configured model name. * @param knowledgeCutoffDate Model's knowledge cutoff date, if known * @param promptContributors List of prompt contributors for this model. * Knowledge cutoff is automatically included if knowledgeCutoffDate is set. @@ -124,13 +126,13 @@ data class SpringAiLlmService @JvmOverloads constructor( */ override val model: ChatModel get() = chatModel - fun buildChatOptions(llmOptions: LlmOptions): ChatOptions = - optionsConverter.convertOptions(llmOptions).mutate().model(name).build() + fun convertOptions(llmOptions: LlmOptions): ChatOptions = + optionsConverter.convertOptions(llmOptions, name) override fun createMessageSender(options: LlmOptions): LlmMessageSender { return SpringAiLlmMessageSender( chatModel = chatModel, - chatOptions = buildChatOptions(options), + chatOptions = convertOptions(options), toolResponseContentAdapter = toolResponseContentAdapter, nativeStructuredOutputConfigurer = nativeStructuredOutputConfigurer, nativeSupport = nativeSupport, @@ -140,7 +142,7 @@ data class SpringAiLlmService @JvmOverloads constructor( override fun createMessageStreamer(options: LlmOptions): LlmMessageStreamer { val chatClient = ChatClient.create(chatModel) - return SpringAiLlmMessageStreamer(chatClient, buildChatOptions(options)) + return SpringAiLlmMessageStreamer(chatClient, convertOptions(options)) } override fun supportsStreaming(): Boolean = StreamingCapabilityVerifier.supportsStreaming(chatModel) diff --git a/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/springai/streaming/StreamingChatClientOperations.kt b/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/springai/streaming/StreamingChatClientOperations.kt index a2d619e3d..734edbe86 100644 --- a/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/springai/streaming/StreamingChatClientOperations.kt +++ b/embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/support/springai/streaming/StreamingChatClientOperations.kt @@ -174,7 +174,7 @@ internal class StreamingChatClientOperations( val userMessages = messages.filterIsInstance() validateUserInput(userMessages, interaction, llmRequestEvent?.agentProcess?.blackboard) - val chatOptions = requireSpringAiLlm(llm).buildChatOptions(interaction.llm) + val chatOptions = requireSpringAiLlm(llm).convertOptions(interaction.llm) // Resolve tool groups and decorate tools val tools = chatClientLlmOperations.resolveAndDecorateTools(interaction, agentProcess, action) @@ -346,7 +346,7 @@ internal class StreamingChatClientOperations( // Chat Client val chatClient = chatClientLlmOperations.createChatClient(llm) // Chat Options, additional potential option "streaming" - val chatOptions = requireSpringAiLlm(llm).buildChatOptions(interaction.llm) + val chatOptions = requireSpringAiLlm(llm).convertOptions(interaction.llm) // Spring AI 2.0's StreamingJacksonOutputConverter requires T : Any; // erase O via Class for the construction, cast result back at use sites. diff --git a/embabel-agent-api/src/test/kotlin/com/embabel/agent/spi/support/springai/SpringAiLlmServiceTest.kt b/embabel-agent-api/src/test/kotlin/com/embabel/agent/spi/support/springai/SpringAiLlmServiceTest.kt index 3959e1cf0..90849c229 100644 --- a/embabel-agent-api/src/test/kotlin/com/embabel/agent/spi/support/springai/SpringAiLlmServiceTest.kt +++ b/embabel-agent-api/src/test/kotlin/com/embabel/agent/spi/support/springai/SpringAiLlmServiceTest.kt @@ -251,38 +251,38 @@ class SpringAiLlmServiceTest { } @Nested - inner class BuildChatOptionsTests { + inner class ConvertOptionsTests { @Test - fun `buildChatOptions stamps service name as model`() { + fun `convertOptions stamps service name as model`() { val service = SpringAiLlmService( name = "my-model", provider = "Provider", chatModel = mockChatModel, ) - assertThat(service.buildChatOptions(LlmOptions()).model).isEqualTo("my-model") + assertThat(service.convertOptions(LlmOptions()).model).isEqualTo("my-model") } @Test - fun `buildChatOptions overrides converter default model with service name`() { + fun `convertOptions overrides converter default model with service name`() { val service = SpringAiLlmService( name = "my-model", provider = "Provider", chatModel = mockChatModel, ) - val result = service.buildChatOptions(LlmOptions()) + val result = service.convertOptions(LlmOptions()) assertThat(result.model).isEqualTo("my-model") assertThat(result.model).isNotEqualTo("some-default-model") } @Test - fun `buildChatOptions preserves converter fields alongside model`() { + fun `convertOptions preserves converter fields alongside model`() { val service = SpringAiLlmService( name = "my-model", provider = "Provider", chatModel = mockChatModel, ) - val result = service.buildChatOptions(LlmOptions().withTemperature(0.5).withMaxTokens(100)) + val result = service.convertOptions(LlmOptions().withTemperature(0.5).withMaxTokens(100)) assertThat(result.model).isEqualTo("my-model") assertThat(result.temperature).isEqualTo(0.5) assertThat(result.maxTokens).isEqualTo(100) diff --git a/embabel-agent-autoconfigure/models/embabel-agent-deepseek-autoconfigure/src/test/kotlin/com/embabel/agent/config/models/deepseek/DeepSeekOptionsConverterTest.kt b/embabel-agent-autoconfigure/models/embabel-agent-deepseek-autoconfigure/src/test/kotlin/com/embabel/agent/config/models/deepseek/DeepSeekOptionsConverterTest.kt index 271955322..6377465b5 100644 --- a/embabel-agent-autoconfigure/models/embabel-agent-deepseek-autoconfigure/src/test/kotlin/com/embabel/agent/config/models/deepseek/DeepSeekOptionsConverterTest.kt +++ b/embabel-agent-autoconfigure/models/embabel-agent-deepseek-autoconfigure/src/test/kotlin/com/embabel/agent/config/models/deepseek/DeepSeekOptionsConverterTest.kt @@ -21,6 +21,8 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.springframework.ai.deepseek.DeepSeekChatOptions +// Calls the deprecated 1-arg convertOptions() directly to verify field mapping in isolation. +// Model stamping is not tested here — it is covered by OptionsConverter.convertOptions(options, model). class DeepSeekOptionsConverterTest : OptionsConverterTestSupport( optionsConverter = DeepSeekOptionsConverter ) { diff --git a/embabel-agent-autoconfigure/models/embabel-agent-google-genai-autoconfigure/src/test/kotlin/com/embabel/agent/config/models/googlegenai/GoogleGenAiOptionsConverterTest.kt b/embabel-agent-autoconfigure/models/embabel-agent-google-genai-autoconfigure/src/test/kotlin/com/embabel/agent/config/models/googlegenai/GoogleGenAiOptionsConverterTest.kt index 3339a4ff7..884374cd4 100644 --- a/embabel-agent-autoconfigure/models/embabel-agent-google-genai-autoconfigure/src/test/kotlin/com/embabel/agent/config/models/googlegenai/GoogleGenAiOptionsConverterTest.kt +++ b/embabel-agent-autoconfigure/models/embabel-agent-google-genai-autoconfigure/src/test/kotlin/com/embabel/agent/config/models/googlegenai/GoogleGenAiOptionsConverterTest.kt @@ -23,6 +23,8 @@ import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test import org.springframework.ai.google.genai.GoogleGenAiChatOptions +// Calls the deprecated 1-arg convertOptions() directly to verify field mapping in isolation. +// Model stamping is not tested here — it is covered by OptionsConverter.convertOptions(options, model). class GoogleGenAiOptionsConverterTest : OptionsConverterTestSupport( optionsConverter = GoogleGenAiOptionsConverter ) { diff --git a/embabel-agent-autoconfigure/models/embabel-agent-minimax-autoconfigure/src/test/kotlin/com/embabel/agent/config/models/minimax/MiniMaxOptionsConverterTest.kt b/embabel-agent-autoconfigure/models/embabel-agent-minimax-autoconfigure/src/test/kotlin/com/embabel/agent/config/models/minimax/MiniMaxOptionsConverterTest.kt index 955f08e91..9fb5095d7 100644 --- a/embabel-agent-autoconfigure/models/embabel-agent-minimax-autoconfigure/src/test/kotlin/com/embabel/agent/config/models/minimax/MiniMaxOptionsConverterTest.kt +++ b/embabel-agent-autoconfigure/models/embabel-agent-minimax-autoconfigure/src/test/kotlin/com/embabel/agent/config/models/minimax/MiniMaxOptionsConverterTest.kt @@ -22,6 +22,8 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.springframework.ai.openai.OpenAiChatOptions +// Calls the deprecated 1-arg convertOptions() directly to verify field mapping in isolation. +// Model stamping is not tested here — it is covered by OptionsConverter.convertOptions(options, model). class MiniMaxOptionsConverterTest : OptionsConverterTestSupport( optionsConverter = MiniMaxOptionsConverter ) { diff --git a/embabel-agent-autoconfigure/models/embabel-agent-ollama-autoconfigure/src/test/kotlin/com/embabel/agent/config/models/ollama/OllamaOptionsConverterTest.kt b/embabel-agent-autoconfigure/models/embabel-agent-ollama-autoconfigure/src/test/kotlin/com/embabel/agent/config/models/ollama/OllamaOptionsConverterTest.kt index 332ce91e1..f18344a4a 100644 --- a/embabel-agent-autoconfigure/models/embabel-agent-ollama-autoconfigure/src/test/kotlin/com/embabel/agent/config/models/ollama/OllamaOptionsConverterTest.kt +++ b/embabel-agent-autoconfigure/models/embabel-agent-ollama-autoconfigure/src/test/kotlin/com/embabel/agent/config/models/ollama/OllamaOptionsConverterTest.kt @@ -23,6 +23,8 @@ import org.junit.jupiter.api.Test import org.springframework.ai.ollama.api.OllamaChatOptions import org.springframework.ai.ollama.api.ThinkOption +// Calls the deprecated 1-arg convertOptions() directly to verify field mapping in isolation. +// Model stamping is not tested here — it is covered by OptionsConverter.convertOptions(options, model). class OllamaOptionsConverterTest : OptionsConverterTestSupport( optionsConverter = OllamaOptionsConverter ) { diff --git a/embabel-agent-autoconfigure/models/embabel-agent-zai-autoconfigure/src/test/kotlin/com/embabel/agent/config/models/zai/ZaiOptionsConverterTest.kt b/embabel-agent-autoconfigure/models/embabel-agent-zai-autoconfigure/src/test/kotlin/com/embabel/agent/config/models/zai/ZaiOptionsConverterTest.kt index 6cac281a1..430ad30f7 100644 --- a/embabel-agent-autoconfigure/models/embabel-agent-zai-autoconfigure/src/test/kotlin/com/embabel/agent/config/models/zai/ZaiOptionsConverterTest.kt +++ b/embabel-agent-autoconfigure/models/embabel-agent-zai-autoconfigure/src/test/kotlin/com/embabel/agent/config/models/zai/ZaiOptionsConverterTest.kt @@ -23,6 +23,8 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.springframework.ai.openai.OpenAiChatOptions +// Calls the deprecated 1-arg convertOptions() directly to verify field mapping in isolation. +// Model stamping is not tested here — it is covered by OptionsConverter.convertOptions(options, model). class ZaiOptionsConverterTest : OptionsConverterTestSupport( optionsConverter = ZaiOptionsConverter ) { diff --git a/embabel-agent-common/embabel-agent-ai/src/main/kotlin/com/embabel/common/ai/model/OptionsConverter.kt b/embabel-agent-common/embabel-agent-ai/src/main/kotlin/com/embabel/common/ai/model/OptionsConverter.kt index ff344ec21..0f5be9fa2 100644 --- a/embabel-agent-common/embabel-agent-ai/src/main/kotlin/com/embabel/common/ai/model/OptionsConverter.kt +++ b/embabel-agent-common/embabel-agent-ai/src/main/kotlin/com/embabel/common/ai/model/OptionsConverter.kt @@ -19,10 +19,25 @@ import org.springframework.ai.chat.prompt.ChatOptions import org.springframework.ai.model.tool.ToolCallingChatOptions /** - * Convert our LLM options to Spring AI ChatOptions + * Convert our LLM options to Spring AI ChatOptions. + * + * Prefer [convertOptions] with an explicit model over the no-model overload. + * The no-model form is deprecated because provider converters carry hardcoded + * default models that will silently go to the wire if not overridden. */ +// TODO: update all converter implementations to override convertOptions(options, model) directly, +// then remove the deprecated 1-arg form and the @Suppress below. fun interface OptionsConverter { + + @Deprecated( + message = "Provide the model explicitly — provider converters carry hardcoded defaults that bypass the configured model.", + replaceWith = ReplaceWith("convertOptions(options, model)"), + ) fun convertOptions(options: LlmOptions): O + + @Suppress("DEPRECATION") + fun convertOptions(options: LlmOptions, model: String): ChatOptions = + convertOptions(options).mutate().model(model).build() } /** diff --git a/embabel-agent-test-support/embabel-agent-test-internal/src/main/kotlin/com/embabel/agent/test/models/optionsConverterTestUtils.kt b/embabel-agent-test-support/embabel-agent-test-internal/src/main/kotlin/com/embabel/agent/test/models/optionsConverterTestUtils.kt index 6677a3721..794966a20 100644 --- a/embabel-agent-test-support/embabel-agent-test-internal/src/main/kotlin/com/embabel/agent/test/models/optionsConverterTestUtils.kt +++ b/embabel-agent-test-support/embabel-agent-test-internal/src/main/kotlin/com/embabel/agent/test/models/optionsConverterTestUtils.kt @@ -19,6 +19,8 @@ import com.embabel.common.ai.model.LlmOptions import com.embabel.common.ai.model.OptionsConverter import org.junit.jupiter.api.Assertions.assertEquals +// Calls the deprecated 1-arg convertOptions() directly to verify field mapping in isolation. +// Model stamping is not tested here — it is covered by OptionsConverter.convertOptions(options, model). fun checkOptionsConverterPreservesCoreValues(optionsConverter: OptionsConverter<*>) { val llmo = LlmOptions().withTemperature(temperature = 0.5).withTopK(10).withTopP(.2).withFrequencyPenalty(.2) val options = optionsConverter.convertOptions(llmo)