Skip to content

Commit a78fa45

Browse files
Merge branch 'main' into fix/persist-barrier-stackoverflow
2 parents b475909 + 285547b commit a78fa45

20 files changed

Lines changed: 1456 additions & 231 deletions

File tree

contrib/spring-ai/src/main/java/com/google/adk/models/springai/MessageConverter.java

Lines changed: 96 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,26 @@ public MessageConverter(ObjectMapper objectMapper) {
8383
* @return A Spring AI Prompt
8484
*/
8585
public Prompt toLlmPrompt(LlmRequest llmRequest) {
86+
return toLlmPrompt(llmRequest, null);
87+
}
88+
89+
/**
90+
* Converts an ADK LlmRequest to a Spring AI Prompt, using the target model's own default options
91+
* as the base for the prompt options.
92+
*
93+
* <p>Provider-specific chat models (for example Spring AI OpenAI {@code 2.0.0}) cast {@code
94+
* Prompt.getOptions()} directly to their own options type (e.g. {@code OpenAiChatOptions}) in
95+
* {@code createRequest(...)}. Passing provider-neutral options such as {@code
96+
* DefaultToolCallingChatOptions} therefore triggers a {@link ClassCastException}. To stay
97+
* compatible with any provider, the prompt options are built on top of the model's own default
98+
* options (obtained via {@code ChatModel.getOptions()}) so the resulting options keep the
99+
* concrete type the provider expects, while overlaying the ADK tools and generation config.
100+
*
101+
* @param llmRequest The ADK request to convert
102+
* @param modelDefaultOptions The target model's default options, or {@code null} if unavailable
103+
* @return A Spring AI Prompt
104+
*/
105+
public Prompt toLlmPrompt(LlmRequest llmRequest, ChatOptions modelDefaultOptions) {
86106
List<Message> messages = new ArrayList<>();
87107
List<String> allSystemMessages = new ArrayList<>();
88108

@@ -119,56 +139,90 @@ public Prompt toLlmPrompt(LlmRequest llmRequest) {
119139
// Add all non-system messages
120140
messages.addAll(nonSystemMessages);
121141

122-
// Convert config to ChatOptions
123-
ChatOptions chatOptions = configMapper.toSpringAiChatOptions(llmRequest.config());
142+
return new Prompt(messages, buildChatOptions(llmRequest, modelDefaultOptions));
143+
}
124144

125-
// Convert ADK tools to Spring AI ToolCallback and add to ChatOptions
145+
/**
146+
* Builds the Spring AI {@link ChatOptions} for a request by overlaying the ADK generation config
147+
* and tools on top of the model's own default options.
148+
*
149+
* @param llmRequest The ADK request being converted
150+
* @param modelDefaultOptions The target model's default options, or {@code null} if unavailable
151+
* @return The chat options to attach to the prompt, or {@code null} when there is nothing
152+
* ADK-specific to configure (letting the model apply its own defaults)
153+
*/
154+
private ChatOptions buildChatOptions(LlmRequest llmRequest, ChatOptions modelDefaultOptions) {
155+
// ADK generation config (temperature, max tokens, ...) as provider-neutral options.
156+
ChatOptions adkChatOptions = configMapper.toSpringAiChatOptions(llmRequest.config());
157+
158+
// ADK tools converted to Spring AI tool callbacks.
159+
List<ToolCallback> toolCallbacks = List.of();
126160
if (llmRequest.tools() != null && !llmRequest.tools().isEmpty()) {
127-
List<ToolCallback> toolCallbacks = toolConverter.convertToSpringAiTools(llmRequest.tools());
128-
if (!toolCallbacks.isEmpty()) {
129-
// Create new ChatOptions with tools included
130-
ToolCallingChatOptions.Builder optionsBuilder = ToolCallingChatOptions.builder();
161+
toolCallbacks = toolConverter.convertToSpringAiTools(llmRequest.tools());
162+
}
131163

132-
// Always set tool callbacks
133-
optionsBuilder.toolCallbacks(toolCallbacks);
164+
boolean hasTools = !toolCallbacks.isEmpty();
165+
boolean hasAdkConfig = adkChatOptions != null;
134166

135-
// Copy existing chat options properties if present
136-
if (chatOptions != null) {
137-
// Copy all relevant properties from existing ChatOptions
138-
if (chatOptions.getTemperature() != null) {
139-
optionsBuilder.temperature(chatOptions.getTemperature());
140-
}
141-
if (chatOptions.getMaxTokens() != null) {
142-
optionsBuilder.maxTokens(chatOptions.getMaxTokens());
143-
}
144-
if (chatOptions.getTopP() != null) {
145-
optionsBuilder.topP(chatOptions.getTopP());
146-
}
147-
if (chatOptions.getTopK() != null) {
148-
optionsBuilder.topK(chatOptions.getTopK());
149-
}
150-
if (chatOptions.getStopSequences() != null) {
151-
optionsBuilder.stopSequences(chatOptions.getStopSequences());
152-
}
153-
// Copy model name if present
154-
if (chatOptions.getModel() != null) {
155-
optionsBuilder.model(chatOptions.getModel());
156-
}
157-
// Copy frequency penalty if present
158-
if (chatOptions.getFrequencyPenalty() != null) {
159-
optionsBuilder.frequencyPenalty(chatOptions.getFrequencyPenalty());
160-
}
161-
// Copy presence penalty if present
162-
if (chatOptions.getPresencePenalty() != null) {
163-
optionsBuilder.presencePenalty(chatOptions.getPresencePenalty());
164-
}
165-
}
167+
// Nothing ADK-specific to add: let the provider fall back to its own default options.
168+
if (!hasTools && !hasAdkConfig) {
169+
return null;
170+
}
166171

167-
chatOptions = optionsBuilder.build();
172+
// Preferred path: start from the model's own options so the resulting options keep the concrete
173+
// provider type (e.g. OpenAiChatOptions) and preserve provider-specific settings such as the
174+
// API key, base URL and model name. This avoids the ClassCastException thrown by providers
175+
// (like Spring AI OpenAI 2.0.0) that cast Prompt.getOptions() to their own options type.
176+
if (modelDefaultOptions instanceof ToolCallingChatOptions) {
177+
ToolCallingChatOptions.Builder<?> optionsBuilder =
178+
((ToolCallingChatOptions) modelDefaultOptions).mutate();
179+
if (hasTools) {
180+
optionsBuilder.toolCallbacks(toolCallbacks);
168181
}
182+
applyGenerationConfig(optionsBuilder, adkChatOptions);
183+
return optionsBuilder.build();
169184
}
170185

171-
return new Prompt(messages, chatOptions);
186+
// Fallback: the model's default options are unavailable or not tool-calling capable. Preserve
187+
// the provider-neutral behavior, which works for providers that normalize generic options.
188+
if (hasTools) {
189+
ToolCallingChatOptions.Builder<?> optionsBuilder = ToolCallingChatOptions.builder();
190+
optionsBuilder.toolCallbacks(toolCallbacks);
191+
applyGenerationConfig(optionsBuilder, adkChatOptions);
192+
return optionsBuilder.build();
193+
}
194+
return adkChatOptions;
195+
}
196+
197+
/** Copies the non-null generation parameters from {@code source} onto {@code builder}. */
198+
private void applyGenerationConfig(ChatOptions.Builder<?> builder, ChatOptions source) {
199+
if (source == null) {
200+
return;
201+
}
202+
if (source.getTemperature() != null) {
203+
builder.temperature(source.getTemperature());
204+
}
205+
if (source.getMaxTokens() != null) {
206+
builder.maxTokens(source.getMaxTokens());
207+
}
208+
if (source.getTopP() != null) {
209+
builder.topP(source.getTopP());
210+
}
211+
if (source.getTopK() != null) {
212+
builder.topK(source.getTopK());
213+
}
214+
if (source.getStopSequences() != null) {
215+
builder.stopSequences(source.getStopSequences());
216+
}
217+
if (source.getModel() != null) {
218+
builder.model(source.getModel());
219+
}
220+
if (source.getFrequencyPenalty() != null) {
221+
builder.frequencyPenalty(source.getFrequencyPenalty());
222+
}
223+
if (source.getPresencePenalty() != null) {
224+
builder.presencePenalty(source.getPresencePenalty());
225+
}
172226
}
173227

174228
/**

contrib/spring-ai/src/main/java/com/google/adk/models/springai/SpringAI.java

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import org.springframework.ai.chat.model.ChatModel;
3030
import org.springframework.ai.chat.model.ChatResponse;
3131
import org.springframework.ai.chat.model.StreamingChatModel;
32+
import org.springframework.ai.chat.prompt.ChatOptions;
3233
import org.springframework.ai.chat.prompt.Prompt;
3334
import reactor.core.publisher.Flux;
3435

@@ -170,7 +171,7 @@ private Flowable<LlmResponse> generateContent(LlmRequest llmRequest) {
170171
observabilityHandler.startRequest(model(), "chat");
171172

172173
try {
173-
Prompt prompt = messageConverter.toLlmPrompt(llmRequest);
174+
Prompt prompt = messageConverter.toLlmPrompt(llmRequest, resolveDefaultOptions());
174175
observabilityHandler.logRequest(prompt.toString(), model());
175176

176177
ChatResponse chatResponse = chatModel.call(prompt);
@@ -200,7 +201,7 @@ private Flowable<LlmResponse> generateStreamingContent(LlmRequest llmRequest) {
200201
return Flowable.create(
201202
emitter -> {
202203
try {
203-
Prompt prompt = messageConverter.toLlmPrompt(llmRequest);
204+
Prompt prompt = messageConverter.toLlmPrompt(llmRequest, resolveDefaultOptions());
204205
observabilityHandler.logRequest(prompt.toString(), model());
205206

206207
Flux<ChatResponse> responseFlux = streamingChatModel.stream(prompt);
@@ -256,6 +257,25 @@ public BaseLlmConnection connect(LlmRequest llmRequest) {
256257
"Live connection is not supported for Spring AI models.");
257258
}
258259

260+
/**
261+
* Returns the underlying model's own default {@link ChatOptions}, or {@code null} if they cannot
262+
* be determined.
263+
*
264+
* <p>These are used as the base for the prompt options so provider-specific models (e.g. Spring
265+
* AI OpenAI) receive options of the concrete type they expect, avoiding a {@link
266+
* ClassCastException} when they cast {@code Prompt.getOptions()} to their provider-specific
267+
* options type.
268+
*/
269+
private ChatOptions resolveDefaultOptions() {
270+
if (chatModel != null) {
271+
return chatModel.getOptions();
272+
}
273+
if (streamingChatModel instanceof ChatModel) {
274+
return ((ChatModel) streamingChatModel).getOptions();
275+
}
276+
return null;
277+
}
278+
259279
private static String extractModelName(Object model) {
260280
// Spring AI models may not always have a straightforward way to get model name
261281
// This is a fallback that can be overridden by providing explicit model name

contrib/spring-ai/src/test/java/com/google/adk/models/springai/MessageConverterTest.java

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,17 @@
2121
import com.fasterxml.jackson.databind.ObjectMapper;
2222
import com.google.adk.models.LlmRequest;
2323
import com.google.adk.models.LlmResponse;
24+
import com.google.adk.tools.BaseTool;
2425
import com.google.genai.types.Content;
2526
import com.google.genai.types.FunctionCall;
27+
import com.google.genai.types.FunctionDeclaration;
2628
import com.google.genai.types.FunctionResponse;
29+
import com.google.genai.types.GenerateContentConfig;
2730
import com.google.genai.types.Part;
31+
import com.google.genai.types.Schema;
2832
import java.util.List;
2933
import java.util.Map;
34+
import java.util.Optional;
3035
import org.junit.jupiter.api.BeforeEach;
3136
import org.junit.jupiter.api.Test;
3237
import org.springframework.ai.chat.messages.AssistantMessage;
@@ -37,7 +42,10 @@
3742
import org.springframework.ai.chat.metadata.DefaultUsage;
3843
import org.springframework.ai.chat.model.ChatResponse;
3944
import org.springframework.ai.chat.model.Generation;
45+
import org.springframework.ai.chat.prompt.ChatOptions;
4046
import org.springframework.ai.chat.prompt.Prompt;
47+
import org.springframework.ai.model.tool.ToolCallingChatOptions;
48+
import org.springframework.ai.openai.OpenAiChatOptions;
4149

4250
class MessageConverterTest {
4351

@@ -667,6 +675,104 @@ void testUserMessageWithInvalidMimeTypeGracefullySkipsMediaPart() {
667675
assertThat(userMessage.getMedia()).isEmpty();
668676
}
669677

678+
private static BaseTool testTool() {
679+
FunctionDeclaration function =
680+
FunctionDeclaration.builder()
681+
.name("get_weather")
682+
.description("Get the current weather for a location")
683+
.parameters(
684+
Schema.builder()
685+
.type("OBJECT")
686+
.properties(Map.of("location", Schema.builder().type("STRING").build()))
687+
.required(List.of("location"))
688+
.build())
689+
.build();
690+
return new BaseTool("get_weather", "Get the current weather for a location") {
691+
@Override
692+
public Optional<FunctionDeclaration> declaration() {
693+
return Optional.of(function);
694+
}
695+
};
696+
}
697+
698+
@Test
699+
void testToolOptionsPreserveProviderSpecificTypeToAvoidClassCastException() {
700+
// Regression test for b/527041291 (GitHub adk-java #1295): Spring AI OpenAI 2.0.0 casts
701+
// Prompt.getOptions() directly to OpenAiChatOptions in createRequest(). When ADK passed a
702+
// provider-neutral DefaultToolCallingChatOptions, that cast threw a ClassCastException. Basing
703+
// the prompt options on the model's own options must keep the concrete provider type.
704+
OpenAiChatOptions modelDefaultOptions =
705+
OpenAiChatOptions.builder().model("gpt-4o").apiKey("dummy-key").build();
706+
707+
LlmRequest request =
708+
LlmRequest.builder()
709+
.contents(
710+
List.of(
711+
Content.builder()
712+
.role("user")
713+
.parts(List.of(Part.fromText("What's the weather in Paris?")))
714+
.build()))
715+
.tools(Map.of("get_weather", testTool()))
716+
.build();
717+
718+
Prompt prompt = messageConverter.toLlmPrompt(request, modelDefaultOptions);
719+
720+
ChatOptions options = prompt.getOptions();
721+
// The exact cast performed by OpenAiChatModel.createRequest(...); must not throw.
722+
assertThat(options).isInstanceOf(OpenAiChatOptions.class);
723+
OpenAiChatOptions openAiOptions = (OpenAiChatOptions) options;
724+
725+
// Tools are attached and provider-specific settings are preserved from the model defaults.
726+
assertThat(openAiOptions.getToolCallbacks()).hasSize(1);
727+
assertThat(openAiOptions.getModel()).isEqualTo("gpt-4o");
728+
assertThat(openAiOptions.getApiKey()).isEqualTo("dummy-key");
729+
}
730+
731+
@Test
732+
void testProviderOptionsPreservedWithConfigOnlyAndNoTools() {
733+
// Even without tools, provider-neutral options previously reached the provider cast. Ensure the
734+
// provider-specific type is preserved and the ADK generation config is overlaid.
735+
OpenAiChatOptions modelDefaultOptions =
736+
OpenAiChatOptions.builder().model("gpt-4o").apiKey("dummy-key").build();
737+
738+
LlmRequest request =
739+
LlmRequest.builder()
740+
.contents(
741+
List.of(
742+
Content.builder().role("user").parts(List.of(Part.fromText("Hello"))).build()))
743+
.config(GenerateContentConfig.builder().temperature(0.25f).build())
744+
.build();
745+
746+
Prompt prompt = messageConverter.toLlmPrompt(request, modelDefaultOptions);
747+
748+
ChatOptions options = prompt.getOptions();
749+
assertThat(options).isInstanceOf(OpenAiChatOptions.class);
750+
assertThat(options.getModel()).isEqualTo("gpt-4o");
751+
assertThat(options.getTemperature()).isEqualTo(0.25);
752+
}
753+
754+
@Test
755+
void testToolOptionsFallBackToGenericWhenNoProviderDefaults() {
756+
// When the model's default options are unavailable, keep the provider-neutral behavior so
757+
// providers that normalize generic options continue to work.
758+
LlmRequest request =
759+
LlmRequest.builder()
760+
.contents(
761+
List.of(
762+
Content.builder()
763+
.role("user")
764+
.parts(List.of(Part.fromText("What's the weather in Paris?")))
765+
.build()))
766+
.tools(Map.of("get_weather", testTool()))
767+
.build();
768+
769+
Prompt prompt = messageConverter.toLlmPrompt(request);
770+
771+
ChatOptions options = prompt.getOptions();
772+
assertThat(options).isInstanceOf(ToolCallingChatOptions.class);
773+
assertThat(((ToolCallingChatOptions) options).getToolCallbacks()).hasSize(1);
774+
}
775+
670776
@Test
671777
void testUserMessageWithMediaOnly() {
672778
// Test conversion with media but no text

core/src/main/java/com/google/adk/agents/RunConfig.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@
1818

1919
import com.google.auto.value.AutoValue;
2020
import com.google.common.collect.ImmutableList;
21+
import com.google.common.collect.ImmutableMap;
2122
import com.google.errorprone.annotations.CanIgnoreReturnValue;
2223
import com.google.genai.types.AudioTranscriptionConfig;
2324
import com.google.genai.types.AvatarConfig;
2425
import com.google.genai.types.Modality;
2526
import com.google.genai.types.SpeechConfig;
27+
import java.util.Map;
2628
import org.jspecify.annotations.Nullable;
2729
import org.slf4j.Logger;
2830
import org.slf4j.LoggerFactory;
@@ -94,6 +96,8 @@ public enum ToolExecutionMode {
9496
@Deprecated
9597
public abstract boolean groupFunctionResponsesInHistory();
9698

99+
public abstract ImmutableMap<String, Object> customMetadata();
100+
97101
public abstract Builder toBuilder();
98102

99103
@SuppressWarnings("deprecation") // Sets the workaround flag's default.
@@ -105,7 +109,8 @@ public static Builder builder() {
105109
.toolExecutionMode(ToolExecutionMode.NONE)
106110
.maxLlmCalls(500)
107111
.autoCreateSession(false)
108-
.groupFunctionResponsesInHistory(false);
112+
.groupFunctionResponsesInHistory(false)
113+
.customMetadata(ImmutableMap.of());
109114
}
110115

111116
@SuppressWarnings("deprecation") // Propagates the workaround flag.
@@ -121,7 +126,8 @@ public static Builder builder(RunConfig runConfig) {
121126
.outputAudioTranscription(runConfig.outputAudioTranscription())
122127
.inputAudioTranscription(runConfig.inputAudioTranscription())
123128
.autoCreateSession(runConfig.autoCreateSession())
124-
.groupFunctionResponsesInHistory(runConfig.groupFunctionResponsesInHistory());
129+
.groupFunctionResponsesInHistory(runConfig.groupFunctionResponsesInHistory())
130+
.customMetadata(runConfig.customMetadata());
125131
}
126132

127133
/** Builder for {@link RunConfig}. */
@@ -216,6 +222,9 @@ public final Builder setAutoCreateSession(boolean autoCreateSession) {
216222
@CanIgnoreReturnValue
217223
public abstract Builder autoCreateSession(boolean autoCreateSession);
218224

225+
@CanIgnoreReturnValue
226+
public abstract Builder customMetadata(Map<String, Object> customMetadata);
227+
219228
/**
220229
* @deprecated Expected only for specific model endpoints.
221230
*/

0 commit comments

Comments
 (0)