From 9aee1546559a0a1e5ea1879bc659626522900c4a Mon Sep 17 00:00:00 2001 From: "jixing.xia" Date: Fri, 30 Oct 2026 11:31:10 +0800 Subject: [PATCH] fix(spring-ai): warn when tool callbacks land on provider-neutral options ChatModel.getOptions() has an interface default returning an empty DefaultChatOptions, so delegating/wrapping ChatModels that do not override it yield non-ToolCallingChatOptions defaults. With ADK tools attached, buildChatOptions then constructs provider-neutral ToolCallingChatOptions, and provider models that cast Prompt.getOptions() to their own type (e.g. OpenAiChatModel .createRequest) fail with a bare ClassCastException several frames away from the cause. Log an actionable WARN in that case (dummy/test models that ignore options keep working); reproduction and analysis in the linked issue. --- .../adk/models/springai/MessageConverter.java | 17 +++ ...geConverterProviderNeutralOptionsTest.java | 106 ++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 contrib/spring-ai/src/test/java/com/google/adk/models/springai/MessageConverterProviderNeutralOptionsTest.java diff --git a/contrib/spring-ai/src/main/java/com/google/adk/models/springai/MessageConverter.java b/contrib/spring-ai/src/main/java/com/google/adk/models/springai/MessageConverter.java index 71ef570d8..262c72f4a 100644 --- a/contrib/spring-ai/src/main/java/com/google/adk/models/springai/MessageConverter.java +++ b/contrib/spring-ai/src/main/java/com/google/adk/models/springai/MessageConverter.java @@ -81,6 +81,10 @@ public class MessageConverter { private static final Logger logger = LoggerFactory.getLogger(MessageConverter.class); + + /** Whether the provider-neutral-options warning was already logged (log once per converter). */ + private boolean loggedProviderNeutralOptionsWarning; + private static final TypeReference> MAP_TYPE_REFERENCE = new TypeReference<>() {}; @@ -454,6 +458,19 @@ public Map getToolRegistry(LlmRequest llmReq } if (hasTools) { + if (modelDefaultOptions != null && !loggedProviderNeutralOptionsWarning) { + // Wrapping ChatModels that keep the interface default getOptions() (an empty + // DefaultChatOptions) get provider-neutral options here; warn once — provider models + // casting Prompt.getOptions() (e.g. OpenAiChatModel) will fail with a bare + // ClassCastException otherwise hard to trace back to the wrapper. + loggedProviderNeutralOptionsWarning = true; + logger.warn( + "Tool callbacks were attached to provider-neutral ToolCallingChatOptions because the" + + " model's default options are not ToolCallingChatOptions; provider models may" + + " fail with a ClassCastException. Wrappers should delegate getOptions() to the" + + " underlying provider model's options.", + modelDefaultOptions.getClass().getName()); + } ToolCallingChatOptions.Builder optionsBuilder = ToolCallingChatOptions.builder(); optionsBuilder.toolCallbacks(toolCallbacks); if (modelDefaultOptions != null && modelDefaultOptions.getModel() != null) { diff --git a/contrib/spring-ai/src/test/java/com/google/adk/models/springai/MessageConverterProviderNeutralOptionsTest.java b/contrib/spring-ai/src/test/java/com/google/adk/models/springai/MessageConverterProviderNeutralOptionsTest.java new file mode 100644 index 000000000..e2487e23f --- /dev/null +++ b/contrib/spring-ai/src/test/java/com/google/adk/models/springai/MessageConverterProviderNeutralOptionsTest.java @@ -0,0 +1,106 @@ +package com.google.adk.models.springai; + +import static org.assertj.core.api.Assertions.assertThat; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.adk.models.LlmRequest; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.tool.ToolCallingChatOptions; + +/** + * When the model's default options are not ToolCallingChatOptions (e.g. a wrapping ChatModel + * keeping the interface default, an empty DefaultChatOptions) and ADK tools are attached, the + * prompt carries provider-neutral ToolCallingChatOptions; the converter logs a warning (once) + * naming the cause. See issue #1731. + */ +class MessageConverterProviderNeutralOptionsTest { + + private final MessageConverter messageConverter = new MessageConverter(new ObjectMapper()); + + private static LlmRequest requestWithTool() { + com.google.genai.types.FunctionDeclaration declaration = + com.google.genai.types.FunctionDeclaration.builder() + .name("ping") + .description("ping tool") + .parameters( + com.google.genai.types.Schema.builder() + .type("OBJECT") + .properties( + Map.of("q", com.google.genai.types.Schema.builder().type("STRING").build())) + .build()) + .build(); + com.google.adk.tools.BaseTool tool = + new com.google.adk.tools.BaseTool("ping", "ping tool") { + @Override + public Optional declaration() { + return Optional.of(declaration); + } + + @Override + public io.reactivex.rxjava3.core.Single> runAsync( + Map args, com.google.adk.tools.ToolContext toolContext) { + return io.reactivex.rxjava3.core.Single.just(Map.of()); + } + }; + return LlmRequest.builder() + .contents( + List.of(Content.builder().role("user").parts(List.of(Part.fromText("hello"))).build())) + .tools(Map.of("ping", tool)) + .build(); + } + + @Test + void nonToolCallingDefaults_logWarningAndAttachToolsToNeutralOptions() { + Logger converterLogger = (Logger) org.slf4j.LoggerFactory.getLogger(MessageConverter.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + converterLogger.addAppender(appender); + try { + // The interface default: an empty DefaultChatOptions, not ToolCallingChatOptions. + ChatOptions defaults = ChatOptions.builder().build(); + assertThat(defaults).isNotInstanceOf(ToolCallingChatOptions.class); + + Prompt prompt = messageConverter.toLlmPrompt(requestWithTool(), defaults); + + ChatOptions options = prompt.getOptions(); + assertThat(options).isInstanceOf(ToolCallingChatOptions.class); + assertThat(((ToolCallingChatOptions) options).getToolCallbacks()).hasSize(1); + + List warnings = + appender.list.stream() + .filter(e -> e.getLevel() == ch.qos.logback.classic.Level.WARN) + .toList(); + assertThat(warnings).hasSize(1); + assertThat(warnings.get(0).getMessage()).contains("provider-neutral"); + + // The warning is logged once per converter, not on every call. + messageConverter.toLlmPrompt(requestWithTool(), defaults); + long warnCount = + appender.list.stream() + .filter(e -> e.getLevel() == ch.qos.logback.classic.Level.WARN) + .count(); + assertThat(warnCount).isEqualTo(1); + } finally { + converterLogger.detachAppender(appender); + } + } + + @Test + void toolCallingDefaults_doNotLogWarning() { + Prompt prompt = + messageConverter.toLlmPrompt(requestWithTool(), ToolCallingChatOptions.builder().build()); + + assertThat(prompt.getOptions()).isInstanceOf(ToolCallingChatOptions.class); + assertThat(((ToolCallingChatOptions) prompt.getOptions()).getToolCallbacks()).hasSize(1); + } +}