diff --git a/contrib/spring-ai/src/main/java/com/google/adk/models/springai/ConfigMapper.java b/contrib/spring-ai/src/main/java/com/google/adk/models/springai/ConfigMapper.java
index 2231813ca..ed0879964 100644
--- a/contrib/spring-ai/src/main/java/com/google/adk/models/springai/ConfigMapper.java
+++ b/contrib/spring-ai/src/main/java/com/google/adk/models/springai/ConfigMapper.java
@@ -15,72 +15,146 @@
*/
package com.google.adk.models.springai;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.google.adk.JsonBaseModel;
+import com.google.common.collect.ImmutableList;
import com.google.genai.types.GenerateContentConfig;
+import com.google.genai.types.Schema;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
import java.util.Optional;
+import java.util.Set;
+import org.jspecify.annotations.Nullable;
import org.springframework.ai.chat.prompt.ChatOptions;
+import org.springframework.ai.model.tool.StructuredOutputChatOptions;
/**
- * Maps ADK GenerateContentConfig to Spring AI ChatOptions.
- *
- *
This mapper handles the translation between ADK's GenerateContentConfig and Spring AI's
- * ChatOptions, enabling configuration parameters like temperature, max tokens, and stop sequences
- * to be passed through to Spring AI models.
+ * Maps between ADK {@link GenerateContentConfig} and Spring AI {@link ChatOptions} in both
+ * directions.
*/
public class ConfigMapper {
+ private static final TypeReference> MAP_TYPE_REFERENCE =
+ new TypeReference<>() {};
+ private static final String JSON_MIME_TYPE = "application/json";
+ private final ToolConverter toolConverter = new ToolConverter();
+
/**
* Converts ADK GenerateContentConfig to Spring AI ChatOptions.
*
+ * When {@link GenerateContentConfig#responseJsonSchema()} or {@link
+ * GenerateContentConfig#responseSchema()} is present, the returned options implement {@link
+ * StructuredOutputChatOptions} carrying the serialized JSON schema.
+ *
* @param config The ADK configuration to convert
* @return Spring AI ChatOptions or null if no config provided
*/
- public ChatOptions toSpringAiChatOptions(Optional config) {
+ public @Nullable ChatOptions toSpringAiChatOptions(Optional config) {
+ Objects.requireNonNull(config, "config cannot be null");
if (config.isEmpty()) {
return null;
}
-
GenerateContentConfig contentConfig = config.get();
- ChatOptions.Builder optionsBuilder = ChatOptions.builder();
- // Map temperature (convert Float to Double)
- contentConfig.temperature().ifPresent(temp -> optionsBuilder.temperature(temp.doubleValue()));
+ // responseJsonSchema may hold a genai Schema, whose JSON uses upper-case types like "STRING".
+ Optional outputSchema =
+ contentConfig
+ .responseJsonSchema()
+ .or(contentConfig::responseSchema)
+ .map(
+ schema ->
+ schema instanceof Schema adkSchema
+ ? toolConverter.convertSchemaToSpringAi(adkSchema)
+ : schema);
+ ChatOptions.Builder> optionsBuilder;
+ if (outputSchema.isPresent()) {
+ try {
+ String schemaJson = JsonBaseModel.getMapper().writeValueAsString(outputSchema.get());
+ optionsBuilder = StructuredOutputChatOptions.builder().outputSchema(schemaJson);
+ } catch (JsonProcessingException e) {
+ throw new IllegalArgumentException("Failed to serialize response schema to JSON", e);
+ }
+ } else {
+ optionsBuilder = ChatOptions.builder();
+ }
- // Map max output tokens
+ contentConfig.temperature().ifPresent(temp -> optionsBuilder.temperature(temp.doubleValue()));
contentConfig.maxOutputTokens().ifPresent(optionsBuilder::maxTokens);
-
- // Map top P (convert Float to Double)
contentConfig.topP().ifPresent(topP -> optionsBuilder.topP(topP.doubleValue()));
-
- // Map top K (convert Float to Integer)
contentConfig.topK().ifPresent(topK -> optionsBuilder.topK(topK.intValue()));
-
- // Map stop sequences
contentConfig
.stopSequences()
.filter(sequences -> !sequences.isEmpty())
.ifPresent(optionsBuilder::stopSequences);
-
- // Map presence penalty (if supported by Spring AI)
contentConfig
.presencePenalty()
- .ifPresent(
- penalty -> {
- // Spring AI may support presence penalty through model-specific options
- // This will be handled in provider-specific adapters
- });
-
- // Map frequency penalty (if supported by Spring AI)
+ .ifPresent(penalty -> optionsBuilder.presencePenalty(penalty.doubleValue()));
contentConfig
.frequencyPenalty()
- .ifPresent(
- penalty -> {
- // Spring AI may support frequency penalty through model-specific options
- // This will be handled in provider-specific adapters
- });
+ .ifPresent(penalty -> optionsBuilder.frequencyPenalty(penalty.doubleValue()));
return optionsBuilder.build();
}
+ /**
+ * Converts Spring AI {@link ChatOptions} to an ADK {@link GenerateContentConfig}.
+ *
+ * When {@code chatOptions} implements {@link StructuredOutputChatOptions} with a non-blank
+ * output schema, the parsed schema is set as {@link GenerateContentConfig#responseJsonSchema()}
+ * with a JSON response MIME type.
+ *
+ * @param chatOptions The Spring AI chat options to convert, or null
+ * @return ADK {@link GenerateContentConfig}, or null if {@code chatOptions} is null
+ */
+ public @Nullable GenerateContentConfig toGenerateContentConfig(
+ @Nullable ChatOptions chatOptions) {
+ if (chatOptions == null) {
+ return null;
+ }
+
+ GenerateContentConfig.Builder builder = GenerateContentConfig.builder();
+
+ if (chatOptions.getTemperature() != null) {
+ builder.temperature(chatOptions.getTemperature().floatValue());
+ }
+ if (chatOptions.getMaxTokens() != null) {
+ builder.maxOutputTokens(chatOptions.getMaxTokens());
+ }
+ if (chatOptions.getTopP() != null) {
+ builder.topP(chatOptions.getTopP().floatValue());
+ }
+ if (chatOptions.getTopK() != null) {
+ builder.topK(chatOptions.getTopK().floatValue());
+ }
+ if (chatOptions.getStopSequences() != null && !chatOptions.getStopSequences().isEmpty()) {
+ builder.stopSequences(ImmutableList.copyOf(chatOptions.getStopSequences()));
+ }
+ if (chatOptions.getPresencePenalty() != null) {
+ builder.presencePenalty(chatOptions.getPresencePenalty().floatValue());
+ }
+ if (chatOptions.getFrequencyPenalty() != null) {
+ builder.frequencyPenalty(chatOptions.getFrequencyPenalty().floatValue());
+ }
+ if (chatOptions instanceof StructuredOutputChatOptions structuredOptions) {
+ String outputSchema = structuredOptions.getOutputSchema();
+ if (outputSchema != null && !outputSchema.isBlank()) {
+ // Passed through as JSON schema so $defs, $ref and anyOf survive; Schema would drop them.
+ builder
+ .responseJsonSchema(parseOutputSchema(outputSchema))
+ .responseMimeType(JSON_MIME_TYPE);
+ }
+ }
+
+ return builder.build();
+ }
+
/**
* Creates default ChatOptions for cases where no ADK config is provided.
*
@@ -97,46 +171,144 @@ public ChatOptions createDefaultChatOptions() {
* @return true if configuration is valid and supported
*/
public boolean isConfigurationValid(Optional config) {
+ Objects.requireNonNull(config, "config cannot be null");
if (config.isEmpty()) {
- return true; // No config is valid
+ return true;
}
-
GenerateContentConfig contentConfig = config.get();
- // Check for unsupported features
if (contentConfig.responseSchema().isPresent()) {
- // Response schema might not be supported by all Spring AI models
- // This should be logged as a warning
return false;
}
if (contentConfig.responseMimeType().isPresent()) {
- // Response MIME type might not be supported by all Spring AI models
return false;
}
- // Check for reasonable ranges
- if (contentConfig.temperature().isPresent()) {
- float temp = contentConfig.temperature().get();
- if (temp < 0.0f || temp > 2.0f) {
- return false; // Temperature out of reasonable range
- }
+ if (contentConfig.temperature().isPresent()
+ && !isValidTemperature(contentConfig.temperature().get())) {
+ return false;
+ }
+
+ if (contentConfig.topP().isPresent() && !isValidTopP(contentConfig.topP().get())) {
+ return false;
+ }
+
+ if (contentConfig.topK().isPresent() && !isValidTopK(contentConfig.topK().get())) {
+ return false;
}
- if (contentConfig.topP().isPresent()) {
- float topP = contentConfig.topP().get();
- if (topP < 0.0f || topP > 1.0f) {
- return false; // topP out of valid range
+ return true;
+ }
+
+ /**
+ * Validates that Spring AI {@link ChatOptions} values fall within valid ADK generation bounds.
+ *
+ * @param chatOptions The Spring AI chat options to validate, or null
+ * @return true if the options are valid
+ */
+ public boolean isChatOptionsValid(@Nullable ChatOptions chatOptions) {
+ if (chatOptions == null) {
+ return true;
+ }
+
+ if (chatOptions.getTemperature() != null && !isValidTemperature(chatOptions.getTemperature())) {
+ return false;
+ }
+
+ if (chatOptions.getTopP() != null && !isValidTopP(chatOptions.getTopP())) {
+ return false;
+ }
+
+ if (chatOptions.getTopK() != null && !isValidTopK(chatOptions.getTopK())) {
+ return false;
+ }
+
+ return true;
+ }
+
+ private static Map parseOutputSchema(String outputSchema) {
+ try {
+ Map schema =
+ JsonBaseModel.getMapper()
+ .readerFor(MAP_TYPE_REFERENCE)
+ .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
+ .readValue(outputSchema);
+ if (schema == null) {
+ throw new IllegalArgumentException("Spring AI output schema must be a JSON object");
}
+ return schema;
+ } catch (JsonProcessingException e) {
+ throw new IllegalArgumentException("Failed to parse Spring AI output schema", e);
+ }
+ }
+
+ /**
+ * Rewrites a JSON output schema for OpenAI strict structured outputs, as Python ADK's LiteLLM
+ * model does: every object schema gets {@code additionalProperties: false} and lists all its
+ * properties as required, and {@code $ref} nodes lose their sibling keywords.
+ */
+ static String toOpenAiStrictSchema(String outputSchema) {
+ Map schema = parseOutputSchema(outputSchema);
+ enforceOpenAiStrictSchema(schema);
+ try {
+ return JsonBaseModel.getMapper().writeValueAsString(schema);
+ } catch (JsonProcessingException e) {
+ throw new IllegalArgumentException("Failed to serialize response schema to JSON", e);
}
+ }
- if (contentConfig.topK().isPresent()) {
- float topK = contentConfig.topK().get();
- if (topK < 1 || topK > 64) {
- return false; // topK out of valid range
+ private static void enforceOpenAiStrictSchema(Map schema) {
+ if (schema.containsKey("$ref")) {
+ schema.keySet().retainAll(Set.of("$ref"));
+ return;
+ }
+ // A genai Schema serializes types in upper case ("OBJECT"); JSON Schema and OpenAI expect
+ // lower case, so lowercase them first, as Python ADK's lowercase_schema_types does.
+ if (schema.get("type") instanceof String typeName) {
+ schema.put("type", typeName.toLowerCase(Locale.ROOT));
+ } else if (schema.get("type") instanceof List> typeNames) {
+ schema.put(
+ "type",
+ typeNames.stream()
+ .map(t -> t instanceof String name ? name.toLowerCase(Locale.ROOT) : t)
+ .toList());
+ }
+ if ("object".equals(schema.get("type"))
+ && schema.get("properties") instanceof Map, ?> properties) {
+ schema.put("additionalProperties", false);
+ schema.put(
+ "required",
+ properties.keySet().stream().map(String::valueOf).sorted().collect(toImmutableList()));
+ }
+ List subschemas = new ArrayList<>();
+ for (String mapKey : List.of("$defs", "definitions", "properties")) {
+ if (schema.get(mapKey) instanceof Map, ?> map) {
+ subschemas.addAll(map.values());
}
}
+ for (String listKey : List.of("anyOf", "oneOf", "allOf")) {
+ if (schema.get(listKey) instanceof List> list) {
+ subschemas.addAll(list);
+ }
+ }
+ subschemas.add(schema.get("items"));
+ for (Object subschema : subschemas) {
+ if (subschema instanceof Map, ?> map) {
+ enforceOpenAiStrictSchema(ToolConverter.castToMap(map));
+ }
+ }
+ }
- return true;
+ private static boolean isValidTemperature(double temp) {
+ return temp >= 0.0 && temp <= 2.0;
+ }
+
+ private static boolean isValidTopP(double topP) {
+ return topP >= 0.0 && topP <= 1.0;
+ }
+
+ private static boolean isValidTopK(double topK) {
+ return topK >= 1.0 && topK <= 64.0;
}
}
diff --git a/contrib/spring-ai/src/main/java/com/google/adk/models/springai/EmbeddingConverter.java b/contrib/spring-ai/src/main/java/com/google/adk/models/springai/EmbeddingConverter.java
index 2b0e8f5aa..df6dc8cb6 100644
--- a/contrib/spring-ai/src/main/java/com/google/adk/models/springai/EmbeddingConverter.java
+++ b/contrib/spring-ai/src/main/java/com/google/adk/models/springai/EmbeddingConverter.java
@@ -15,24 +15,25 @@
*/
package com.google.adk.models.springai;
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.Floats;
+import com.google.genai.types.ContentEmbedding;
+import com.google.genai.types.EmbedContentResponse;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Comparator;
import java.util.List;
+import java.util.Objects;
+import org.jspecify.annotations.Nullable;
import org.springframework.ai.embedding.Embedding;
import org.springframework.ai.embedding.EmbeddingRequest;
import org.springframework.ai.embedding.EmbeddingResponse;
/**
- * Utility class for converting between embedding formats and performing vector operations.
- *
- * This class provides helper methods for working with embeddings generated by Spring AI models,
- * including format conversions and similarity calculations.
+ * Static utilities for converting between Spring AI and ADK/GenAI embedding formats and performing
+ * vector operations.
*/
-public class EmbeddingConverter {
-
- private EmbeddingConverter() {
- // Utility class - prevent instantiation
- }
+public final class EmbeddingConverter {
/**
* Create an EmbeddingRequest for a single text input.
@@ -41,6 +42,7 @@ private EmbeddingConverter() {
* @return EmbeddingRequest for the text
*/
public static EmbeddingRequest createRequest(String text) {
+ Objects.requireNonNull(text, "text cannot be null");
return new EmbeddingRequest(List.of(text), null);
}
@@ -51,9 +53,19 @@ public static EmbeddingRequest createRequest(String text) {
* @return EmbeddingRequest for the texts
*/
public static EmbeddingRequest createRequest(List texts) {
+ Objects.requireNonNull(texts, "texts cannot be null");
return new EmbeddingRequest(texts, null);
}
+ /** Extracts the input texts from a Spring AI {@link EmbeddingRequest}. */
+ public static ImmutableList extractTexts(EmbeddingRequest request) {
+ Objects.requireNonNull(request, "request cannot be null");
+ if (request.getInstructions() == null) {
+ return ImmutableList.of();
+ }
+ return ImmutableList.copyOf(request.getInstructions());
+ }
+
/**
* Extract embedding vectors from an EmbeddingResponse.
*
@@ -61,6 +73,7 @@ public static EmbeddingRequest createRequest(List texts) {
* @return List of embedding vectors as float arrays
*/
public static List extractEmbeddings(EmbeddingResponse response) {
+ Objects.requireNonNull(response, "response cannot be null");
List embeddings = new ArrayList<>();
for (Embedding embedding : response.getResults()) {
embeddings.add(embedding.getOutput());
@@ -74,13 +87,95 @@ public static List extractEmbeddings(EmbeddingResponse response) {
* @param response The embedding response
* @return The first embedding vector, or null if no embeddings
*/
- public static float[] extractFirstEmbedding(EmbeddingResponse response) {
+ public static float @Nullable [] extractFirstEmbedding(EmbeddingResponse response) {
+ Objects.requireNonNull(response, "response cannot be null");
if (response.getResults().isEmpty()) {
return null;
}
return response.getResults().get(0).getOutput();
}
+ /** Creates a Spring AI {@link Embedding} from a float vector and batch index. */
+ public static Embedding toEmbedding(float[] vector, int index) {
+ Objects.requireNonNull(vector, "vector cannot be null");
+ return new Embedding(Arrays.copyOf(vector, vector.length), index);
+ }
+
+ /** Converts a GenAI {@link ContentEmbedding} to a Spring AI {@link Embedding}. */
+ public static Embedding toEmbedding(ContentEmbedding contentEmbedding, int index) {
+ return new Embedding(extractEmbedding(contentEmbedding), index);
+ }
+
+ /** Creates a Spring AI {@link EmbeddingResponse} from a single embedding vector. */
+ public static EmbeddingResponse toEmbeddingResponse(float[] vector) {
+ Objects.requireNonNull(vector, "vector cannot be null");
+ return toEmbeddingResponse(List.of(vector));
+ }
+
+ /** Creates a Spring AI {@link EmbeddingResponse} from a list of embedding vectors. */
+ public static EmbeddingResponse toEmbeddingResponse(List vectors) {
+ Objects.requireNonNull(vectors, "vectors cannot be null");
+ List embeddings = new ArrayList<>(vectors.size());
+ for (int i = 0; i < vectors.size(); i++) {
+ embeddings.add(toEmbedding(vectors.get(i), i));
+ }
+ return new EmbeddingResponse(embeddings);
+ }
+
+ /** Converts a float array to a GenAI {@link ContentEmbedding}. */
+ public static ContentEmbedding toContentEmbedding(float[] vector) {
+ Objects.requireNonNull(vector, "vector cannot be null");
+ return ContentEmbedding.builder().values(ImmutableList.copyOf(Floats.asList(vector))).build();
+ }
+
+ /** Converts a Spring AI {@link Embedding} to a GenAI {@link ContentEmbedding}. */
+ public static ContentEmbedding toContentEmbedding(Embedding embedding) {
+ Objects.requireNonNull(embedding, "embedding cannot be null");
+ float[] output = embedding.getOutput();
+ return toContentEmbedding(output != null ? output : new float[0]);
+ }
+
+ /** Extracts a float array from a GenAI {@link ContentEmbedding}. */
+ public static float[] extractEmbedding(ContentEmbedding contentEmbedding) {
+ Objects.requireNonNull(contentEmbedding, "contentEmbedding cannot be null");
+ List values = contentEmbedding.values().orElse(List.of());
+ float[] vector = new float[values.size()];
+ for (int i = 0; i < values.size(); i++) {
+ Float val = values.get(i);
+ vector[i] = val != null ? val : 0.0f;
+ }
+ return vector;
+ }
+
+ /**
+ * Converts a Spring AI {@link EmbeddingResponse} to a GenAI {@link EmbedContentResponse},
+ * ordering batch embeddings by {@link Embedding#getIndex()} when present.
+ */
+ public static EmbedContentResponse toEmbedContentResponse(EmbeddingResponse response) {
+ Objects.requireNonNull(response, "response cannot be null");
+ List results = new ArrayList<>(response.getResults());
+ if (results.stream().allMatch(e -> e != null && e.getIndex() != null)) {
+ results.sort(Comparator.comparingInt(Embedding::getIndex));
+ }
+ ImmutableList.Builder embeddings =
+ ImmutableList.builderWithExpectedSize(results.size());
+ for (Embedding embedding : results) {
+ embeddings.add(toContentEmbedding(embedding));
+ }
+ return EmbedContentResponse.builder().embeddings(embeddings.build()).build();
+ }
+
+ /** Converts a GenAI {@link EmbedContentResponse} to a Spring AI {@link EmbeddingResponse}. */
+ public static EmbeddingResponse fromEmbedContentResponse(EmbedContentResponse response) {
+ Objects.requireNonNull(response, "response cannot be null");
+ List contentEmbeddings = response.embeddings().orElse(List.of());
+ List embeddings = new ArrayList<>(contentEmbeddings.size());
+ for (int i = 0; i < contentEmbeddings.size(); i++) {
+ embeddings.add(toEmbedding(contentEmbeddings.get(i), i));
+ }
+ return new EmbeddingResponse(embeddings);
+ }
+
/**
* Calculate cosine similarity between two embedding vectors.
*
@@ -232,4 +327,6 @@ public static float[] toFloatArray(double[] doubleArray) {
}
return floatArray;
}
+
+ private EmbeddingConverter() {}
}
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 815ca2b7b..71ef570d8 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
@@ -15,25 +15,48 @@
*/
package com.google.adk.models.springai;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.adk.models.LlmRequest;
import com.google.adk.models.LlmResponse;
+import com.google.adk.tools.BaseTool;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.genai.types.Blob;
import com.google.genai.types.Content;
+import com.google.genai.types.FileData;
+import com.google.genai.types.FinishReason;
import com.google.genai.types.FunctionCall;
+import com.google.genai.types.FunctionDeclaration;
import com.google.genai.types.FunctionResponse;
+import com.google.genai.types.GenerateContentConfig;
import com.google.genai.types.GenerateContentResponseUsageMetadata;
import com.google.genai.types.Part;
+import com.google.genai.types.Tool;
import java.net.URI;
+import java.net.URL;
import java.util.ArrayList;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import org.jspecify.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
+import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
+import org.springframework.ai.chat.metadata.ChatResponseMetadata;
+import org.springframework.ai.chat.metadata.DefaultUsage;
import org.springframework.ai.chat.metadata.EmptyUsage;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.chat.model.ChatResponse;
@@ -41,26 +64,23 @@
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.content.Media;
+import org.springframework.ai.model.tool.StructuredOutputChatOptions;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.tool.ToolCallback;
+import org.springframework.core.io.ByteArrayResource;
import org.springframework.util.CollectionUtils;
import org.springframework.util.MimeType;
/**
- * Converts between ADK and Spring AI message formats.
- *
- * This converter handles the translation between ADK's Content/Part format (based on Google's
- * genai.types) and Spring AI's Message/ChatResponse format. It supports:
+ * Converts between ADK and Spring AI message, request, and response formats in both directions.
*
- *
- * Text content in all message types
- * Tool/function calls in assistant messages
- * Tool/function responses as {@link ToolResponseMessage}s
- * System instructions and configuration options
- *
+ * This converter handles the translation between ADK's {@link Content}/{@link Part} and {@link
+ * LlmRequest}/{@link LlmResponse} formats and Spring AI's {@link Message} and {@link Prompt}/{@link
+ * ChatResponse} formats.
*/
public class MessageConverter {
+ private static final Logger logger = LoggerFactory.getLogger(MessageConverter.class);
private static final TypeReference> MAP_TYPE_REFERENCE =
new TypeReference<>() {};
@@ -72,12 +92,27 @@ public class MessageConverter {
*/
private static final String THOUGHT_SIGNATURES_KEY = "thoughtSignatures";
+ /** Metadata key the Spring AI Google GenAI provider uses to flag a generation as thought text. */
+ private static final String IS_THOUGHT_KEY = "isThought";
+
+ /** Metadata key the Spring AI Anthropic provider sets only on thinking-block generations. */
+ private static final String ANTHROPIC_THINKING_SIGNATURE_KEY = "signature";
+
+ /** Metadata key the Spring AI Google GenAI provider uses for the candidate index. */
+ private static final String CANDIDATE_INDEX_KEY = "candidateIndex";
+
+ /** Metadata key the Spring AI OpenAI provider uses for the choice index. */
+ private static final String OPENAI_CHOICE_INDEX_KEY = "index";
+
+ /** Package of the Spring AI OpenAI provider, which is not a compile-time dependency. */
+ private static final String OPENAI_PACKAGE = "org.springframework.ai.openai";
+
private final ObjectMapper objectMapper;
private final ToolConverter toolConverter;
private final ConfigMapper configMapper;
public MessageConverter(ObjectMapper objectMapper) {
- this.objectMapper = objectMapper;
+ this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper cannot be null");
this.toolConverter = new ToolConverter();
this.configMapper = new ConfigMapper();
}
@@ -108,46 +143,282 @@ public Prompt toLlmPrompt(LlmRequest llmRequest) {
* @param modelDefaultOptions The target model's default options, or {@code null} if unavailable
* @return A Spring AI Prompt
*/
- public Prompt toLlmPrompt(LlmRequest llmRequest, ChatOptions modelDefaultOptions) {
+ public Prompt toLlmPrompt(LlmRequest llmRequest, @Nullable ChatOptions modelDefaultOptions) {
+ Objects.requireNonNull(llmRequest, "llmRequest cannot be null");
List messages = new ArrayList<>();
- List allSystemMessages = new ArrayList<>();
+ List allSystemMessages = new ArrayList<>(llmRequest.getSystemInstructions());
- // Collect system instructions from LlmRequest
- allSystemMessages.addAll(llmRequest.getSystemInstructions());
-
- // Collect system messages from Content objects
List nonSystemMessages = new ArrayList<>();
for (Content content : llmRequest.contents()) {
- String role = content.role().orElse("user").toLowerCase();
+ String role = content.role().orElse("user").toLowerCase(Locale.ROOT);
if ("system".equals(role)) {
- // Extract text from system content and add to combined system message
- StringBuilder systemText = new StringBuilder();
- for (Part part : content.parts().orElse(List.of())) {
- if (part.text().isPresent()) {
- systemText.append(part.text().get());
- }
- }
- if (systemText.length() > 0) {
- allSystemMessages.add(systemText.toString());
+ String systemText = handleSystemContent(content).getText();
+ if (systemText != null && !systemText.isEmpty()) {
+ allSystemMessages.add(systemText);
}
} else {
- // Handle non-system messages normally
nonSystemMessages.addAll(toSpringAiMessages(content));
}
}
- // Create single combined SystemMessage if any system content exists
if (!allSystemMessages.isEmpty()) {
String combinedSystemMessage = String.join("\n\n", allSystemMessages);
messages.add(new SystemMessage(combinedSystemMessage));
}
- // Add all non-system messages
messages.addAll(nonSystemMessages);
return new Prompt(messages, buildChatOptions(llmRequest, modelDefaultOptions));
}
+ /**
+ * Converts a Spring AI {@link Prompt} to an ADK {@link LlmRequest}.
+ *
+ * System messages are extracted into the request's system instructions, non-system messages
+ * are converted to {@link Content} turns, and {@link Prompt#getOptions()} is mapped to {@link
+ * GenerateContentConfig}, model name, and ADK tools.
+ *
+ * @param prompt The Spring AI prompt to convert
+ * @return An ADK {@link LlmRequest}
+ */
+ public LlmRequest toLlmRequest(Prompt prompt) {
+ Objects.requireNonNull(prompt, "prompt cannot be null");
+ LlmRequest.Builder requestBuilder = LlmRequest.builder();
+
+ List systemInstructions = new ArrayList<>();
+ List contents = new ArrayList<>();
+
+ for (Message message : prompt.getInstructions()) {
+ if (message instanceof SystemMessage systemMessage) {
+ if (systemMessage.getText() != null && !systemMessage.getText().isEmpty()) {
+ systemInstructions.add(systemMessage.getText());
+ }
+ } else {
+ contents.add(toAdkContent(message));
+ }
+ }
+ requestBuilder.contents(ImmutableList.copyOf(contents));
+
+ ChatOptions options = prompt.getOptions();
+ if (options != null) {
+ if (options.getModel() != null && !options.getModel().isBlank()) {
+ requestBuilder.model(options.getModel());
+ }
+ GenerateContentConfig adkConfig = configMapper.toGenerateContentConfig(options);
+ if (options instanceof ToolCallingChatOptions toolOptions
+ && !CollectionUtils.isEmpty(toolOptions.getToolCallbacks())) {
+ List adkTools = toolConverter.convertToAdkTools(toolOptions.getToolCallbacks());
+ requestBuilder.appendTools(adkTools);
+ // Gemini sends only the config, so declare the tools there too, as BaseTool does.
+ ImmutableList declarations =
+ adkTools.stream()
+ .flatMap(tool -> tool.declaration().stream())
+ .collect(toImmutableList());
+ adkConfig =
+ adkConfig.toBuilder()
+ .tools(ImmutableList.of(Tool.builder().functionDeclarations(declarations).build()))
+ .build();
+ }
+ requestBuilder.config(adkConfig);
+ }
+
+ if (!systemInstructions.isEmpty()) {
+ requestBuilder.appendInstructions(systemInstructions);
+ }
+
+ return requestBuilder.build();
+ }
+
+ /**
+ * Converts an ADK {@link Content} to one or more Spring AI {@link Message}s.
+ *
+ * @param content The ADK content to convert
+ * @return An immutable list of Spring AI messages
+ */
+ public ImmutableList toSpringAiMessages(Content content) {
+ Objects.requireNonNull(content, "content cannot be null");
+ String role = content.role().orElse("user").toLowerCase(Locale.ROOT);
+
+ return switch (role) {
+ case "user" -> handleUserContent(content);
+ case "model", "assistant" -> ImmutableList.of(handleAssistantContent(content));
+ case "system" -> ImmutableList.of(handleSystemContent(content));
+ default -> throw new IllegalStateException("Unexpected role: " + role);
+ };
+ }
+
+ /**
+ * Converts a Spring AI {@link Message} to an ADK {@link Content}.
+ *
+ * @param message The Spring AI message to convert
+ * @return The converted ADK {@link Content}
+ */
+ public Content toAdkContent(Message message) {
+ Objects.requireNonNull(message, "message cannot be null");
+ if (message instanceof UserMessage userMessage) {
+ return convertUserMessageToContent(userMessage);
+ }
+ if (message instanceof AssistantMessage assistantMessage) {
+ return convertAssistantMessageToContent(assistantMessage);
+ }
+ if (message instanceof SystemMessage systemMessage) {
+ String text = systemMessage.getText() != null ? systemMessage.getText() : "";
+ return Content.builder().role("system").parts(Part.fromText(text)).build();
+ }
+ if (message instanceof ToolResponseMessage toolResponseMessage) {
+ return convertToolResponseMessageToContent(toolResponseMessage);
+ }
+ throw new IllegalArgumentException("Unexpected message type: " + message.getMessageType());
+ }
+
+ /**
+ * Converts a Spring AI ChatResponse to an ADK LlmResponse.
+ *
+ * @param chatResponse The Spring AI response to convert
+ * @return An ADK LlmResponse
+ */
+ public LlmResponse toLlmResponse(@Nullable ChatResponse chatResponse) {
+ return toLlmResponse(chatResponse, false);
+ }
+
+ /**
+ * Converts a Spring AI ChatResponse to an ADK LlmResponse with streaming context.
+ *
+ * @param chatResponse The Spring AI response to convert
+ * @param isStreaming Whether this is part of a streaming response
+ * @return An ADK LlmResponse
+ */
+ public LlmResponse toLlmResponse(@Nullable ChatResponse chatResponse, boolean isStreaming) {
+ if (chatResponse == null) {
+ return LlmResponse.builder().build();
+ }
+
+ LlmResponse.Builder responseBuilder = LlmResponse.builder();
+ if (!CollectionUtils.isEmpty(chatResponse.getResults())) {
+ List mergedParts = new ArrayList<>();
+ String firstFinishReason = null;
+ Object firstCandidateIndex =
+ chatResponse.getResults().stream()
+ .filter(gen -> gen.getOutput() != null)
+ .findFirst()
+ .map(MessageConverter::candidateIndex)
+ .orElse(null);
+ for (Generation gen : chatResponse.getResults()) {
+ // Providers may split one candidate into several generations; ADK keeps one candidate.
+ if (gen.getOutput() != null && !Objects.equals(candidateIndex(gen), firstCandidateIndex)) {
+ continue;
+ }
+ if (gen.getOutput() != null) {
+ Content genContent = convertAssistantMessageToContent(gen.getOutput());
+ genContent.parts().ifPresent(mergedParts::addAll);
+ }
+ if (firstFinishReason == null
+ && gen.getMetadata() != null
+ && gen.getMetadata().getFinishReason() != null
+ && !gen.getMetadata().getFinishReason().isBlank()) {
+ firstFinishReason = gen.getMetadata().getFinishReason();
+ }
+ }
+
+ Content content = Content.builder().role("model").parts(mergedParts).build();
+ boolean isPartial = isStreaming && isPartialResponse(mergedParts);
+ boolean isTurnComplete = !isStreaming || isTurnCompleteFinishReason(firstFinishReason);
+
+ responseBuilder.content(content).partial(isPartial).turnComplete(isTurnComplete);
+ if (firstFinishReason != null) {
+ FinishReason finishReason = toAdkFinishReason(firstFinishReason);
+ responseBuilder.finishReason(finishReason);
+ // A non-STOP reason is an error, as for the Gemini model and Python ADK's LiteLLM model.
+ if (finishReason.knownEnum() != FinishReason.Known.STOP) {
+ responseBuilder
+ .errorCode(finishReason)
+ .errorMessage(
+ finishReason.knownEnum() == FinishReason.Known.MAX_TOKENS
+ ? "Maximum tokens reached"
+ : "Finished with " + finishReason);
+ }
+ }
+ }
+
+ if (chatResponse.getMetadata() != null) {
+ String responseModel = chatResponse.getMetadata().getModel();
+ if (responseModel != null && !responseModel.isBlank()) {
+ responseBuilder.modelVersion(responseModel);
+ }
+ Usage springUsage = chatResponse.getMetadata().getUsage();
+ if (springUsage != null && !(springUsage instanceof EmptyUsage)) {
+ GenerateContentResponseUsageMetadata adkUsage =
+ GenerateContentResponseUsageMetadata.builder()
+ .promptTokenCount(nullSafeInt(springUsage.getPromptTokens()))
+ .candidatesTokenCount(nullSafeInt(springUsage.getCompletionTokens()))
+ .totalTokenCount(nullSafeInt(springUsage.getTotalTokens()))
+ .build();
+ responseBuilder.usageMetadata(adkUsage);
+ }
+ }
+ return responseBuilder.build();
+ }
+
+ /**
+ * Converts an ADK {@link LlmResponse} to a Spring AI {@link ChatResponse}.
+ *
+ * @param llmResponse The ADK response to convert
+ * @return A Spring AI {@link ChatResponse}
+ */
+ public ChatResponse toChatResponse(@Nullable LlmResponse llmResponse) {
+ if (llmResponse == null) {
+ return new ChatResponse(List.of());
+ }
+
+ List generations = new ArrayList<>();
+ if (llmResponse.content().isPresent() || llmResponse.finishReason().isPresent()) {
+ AssistantMessage assistantMessage =
+ llmResponse
+ .content()
+ .map(this::handleAssistantContent)
+ .orElseGet(() -> new AssistantMessage(""));
+ ChatGenerationMetadata generationMetadata =
+ llmResponse
+ .finishReason()
+ .map(
+ reason ->
+ ChatGenerationMetadata.builder().finishReason(reason.toString()).build())
+ .orElse(ChatGenerationMetadata.NULL);
+ generations.add(new Generation(assistantMessage, generationMetadata));
+ }
+
+ ChatResponseMetadata.Builder metadataBuilder = ChatResponseMetadata.builder();
+ boolean hasMetadata = false;
+ if (llmResponse.modelVersion().isPresent() && !llmResponse.modelVersion().get().isBlank()) {
+ metadataBuilder.model(llmResponse.modelVersion().get());
+ hasMetadata = true;
+ }
+ if (llmResponse.usageMetadata().isPresent()) {
+ GenerateContentResponseUsageMetadata usage = llmResponse.usageMetadata().get();
+ DefaultUsage springUsage =
+ new DefaultUsage(
+ usage.promptTokenCount().orElse(0),
+ usage.candidatesTokenCount().orElse(0),
+ usage.totalTokenCount().orElse(0));
+ metadataBuilder.usage(springUsage);
+ hasMetadata = true;
+ }
+
+ return hasMetadata
+ ? new ChatResponse(generations, metadataBuilder.build())
+ : new ChatResponse(generations);
+ }
+
+ /**
+ * Gets tool registry from ADK tools for internal tracking.
+ *
+ * @param llmRequest The ADK request containing tools
+ * @return Map of tool metadata for tracking available tools
+ */
+ public Map getToolRegistry(LlmRequest llmRequest) {
+ return toolConverter.createToolRegistry(llmRequest.tools());
+ }
+
/**
* Builds the Spring AI {@link ChatOptions} for a request by overlaying the ADK generation config
* and tools on top of the model's own default options.
@@ -157,11 +428,10 @@ public Prompt toLlmPrompt(LlmRequest llmRequest, ChatOptions modelDefaultOptions
* @return The chat options to attach to the prompt, or {@code null} when there is nothing
* ADK-specific to configure (letting the model apply its own defaults)
*/
- private ChatOptions buildChatOptions(LlmRequest llmRequest, ChatOptions modelDefaultOptions) {
- // ADK generation config (temperature, max tokens, ...) as provider-neutral options.
+ private @Nullable ChatOptions buildChatOptions(
+ LlmRequest llmRequest, @Nullable ChatOptions modelDefaultOptions) {
ChatOptions adkChatOptions = configMapper.toSpringAiChatOptions(llmRequest.config());
- // ADK tools converted to Spring AI tool callbacks.
List toolCallbacks = List.of();
if (llmRequest.tools() != null && !llmRequest.tools().isEmpty()) {
toolCallbacks = toolConverter.convertToSpringAiTools(llmRequest.tools());
@@ -170,18 +440,12 @@ private ChatOptions buildChatOptions(LlmRequest llmRequest, ChatOptions modelDef
boolean hasTools = !toolCallbacks.isEmpty();
boolean hasAdkConfig = adkChatOptions != null;
- // Nothing ADK-specific to add: let the provider fall back to its own default options.
if (!hasTools && !hasAdkConfig) {
return null;
}
- // Preferred path: start from the model's own options so the resulting options keep the concrete
- // provider type (e.g. OpenAiChatOptions) and preserve provider-specific settings such as the
- // API key, base URL and model name. This avoids the ClassCastException thrown by providers
- // (like Spring AI OpenAI 2.0.0) that cast Prompt.getOptions() to their own options type.
- if (modelDefaultOptions instanceof ToolCallingChatOptions) {
- ToolCallingChatOptions.Builder> optionsBuilder =
- ((ToolCallingChatOptions) modelDefaultOptions).mutate();
+ if (modelDefaultOptions instanceof ToolCallingChatOptions toolCallingDefaults) {
+ ToolCallingChatOptions.Builder> optionsBuilder = toolCallingDefaults.mutate();
if (hasTools) {
optionsBuilder.toolCallbacks(toolCallbacks);
}
@@ -189,11 +453,12 @@ private ChatOptions buildChatOptions(LlmRequest llmRequest, ChatOptions modelDef
return optionsBuilder.build();
}
- // Fallback: the model's default options are unavailable or not tool-calling capable. Preserve
- // the provider-neutral behavior, which works for providers that normalize generic options.
if (hasTools) {
ToolCallingChatOptions.Builder> optionsBuilder = ToolCallingChatOptions.builder();
optionsBuilder.toolCallbacks(toolCallbacks);
+ if (modelDefaultOptions != null && modelDefaultOptions.getModel() != null) {
+ optionsBuilder.model(modelDefaultOptions.getModel());
+ }
applyGenerationConfig(optionsBuilder, adkChatOptions);
return optionsBuilder.build();
}
@@ -201,7 +466,7 @@ private ChatOptions buildChatOptions(LlmRequest llmRequest, ChatOptions modelDef
}
/** Copies the non-null generation parameters from {@code source} onto {@code builder}. */
- private void applyGenerationConfig(ChatOptions.Builder> builder, ChatOptions source) {
+ private void applyGenerationConfig(ChatOptions.Builder> builder, @Nullable ChatOptions source) {
if (source == null) {
return;
}
@@ -229,36 +494,19 @@ private void applyGenerationConfig(ChatOptions.Builder> builder, ChatOptions s
if (source.getPresencePenalty() != null) {
builder.presencePenalty(source.getPresencePenalty());
}
+ if (source instanceof StructuredOutputChatOptions structuredSource
+ && structuredSource.getOutputSchema() != null
+ && builder instanceof StructuredOutputChatOptions.Builder> structuredBuilder) {
+ String outputSchema = structuredSource.getOutputSchema();
+ // Spring AI's OpenAI provider sends the schema in strict mode, which requires this shape.
+ if (builder.getClass().getPackageName().equals(OPENAI_PACKAGE)) {
+ outputSchema = ConfigMapper.toOpenAiStrictSchema(outputSchema);
+ }
+ structuredBuilder.outputSchema(outputSchema);
+ }
}
- /**
- * Gets tool registry from ADK tools for internal tracking.
- *
- * @param llmRequest The ADK request containing tools
- * @return Map of tool metadata for tracking available tools
- */
- public Map getToolRegistry(LlmRequest llmRequest) {
- return toolConverter.createToolRegistry(llmRequest.tools());
- }
-
- /**
- * Converts an ADK Content to Spring AI Message(s).
- *
- * @param content The ADK content to convert
- * @return A list of Spring AI messages
- */
- private List toSpringAiMessages(Content content) {
- String role = content.role().orElse("user").toLowerCase();
-
- return switch (role) {
- case "user" -> handleUserContent(content);
- case "model", "assistant" -> List.of(handleAssistantContent(content));
- case "system" -> List.of(handleSystemContent(content));
- default -> throw new IllegalStateException("Unexpected role: " + role);
- };
- }
-
- private List handleUserContent(Content content) {
+ private ImmutableList handleUserContent(Content content) {
StringBuilder textBuilder = new StringBuilder();
List toolResponses = new ArrayList<>();
List mediaList = new ArrayList<>();
@@ -273,39 +521,12 @@ private List handleUserContent(Content content) {
functionResponse.id().orElse(""),
functionResponse.name().orElse(""),
toJson(functionResponse.response().orElse(Map.of()))));
- } else if (part.inlineData().isPresent()) {
- // Handle inline media data (images, audio, video, etc.)
- com.google.genai.types.Blob blob = part.inlineData().get();
- if (blob.mimeType().isPresent() && blob.data().isPresent()) {
- try {
- MimeType mimeType = MimeType.valueOf(blob.mimeType().get());
- // Create Media object from inline data using ByteArrayResource
- org.springframework.core.io.ByteArrayResource resource =
- new org.springframework.core.io.ByteArrayResource(blob.data().get());
- mediaList.add(new Media(mimeType, resource));
- } catch (Exception e) {
- // Log warning but continue processing other parts
- // In production, consider proper logging framework
- System.err.println("Warning: Failed to process media part: " + e.getMessage());
- }
- }
- } else if (part.fileData().isPresent()) {
- // Handle file-based media (URI references)
- com.google.genai.types.FileData fileData = part.fileData().get();
- if (fileData.mimeType().isPresent() && fileData.fileUri().isPresent()) {
- try {
- MimeType mimeType = MimeType.valueOf(fileData.mimeType().get());
- // Create Media object from file URI
- URI uri = URI.create(fileData.fileUri().get());
- mediaList.add(new Media(mimeType, uri));
- } catch (Exception e) {
- System.err.println("Warning: Failed to process media part: " + e.getMessage());
- }
- }
+ } else {
+ partToMedia(part).ifPresent(mediaList::add);
}
}
- List messages = new ArrayList<>();
+ ImmutableList.Builder messages = ImmutableList.builder();
String text = textBuilder.toString();
// Emit a UserMessage for any text/media, or for an otherwise-empty turn; but when the turn only
// carries function responses, emit just the ToolResponseMessage so the request does not end on
@@ -317,35 +538,38 @@ private List handleUserContent(Content content) {
messages.add(ToolResponseMessage.builder().responses(toolResponses).build());
}
- return messages;
+ return messages.build();
}
private AssistantMessage handleAssistantContent(Content content) {
StringBuilder textBuilder = new StringBuilder();
List toolCalls = new ArrayList<>();
List thoughtSignatures = new ArrayList<>();
+ List mediaList = new ArrayList<>();
for (Part part : content.parts().orElse(List.of())) {
if (part.text().isPresent()) {
- textBuilder.append(part.text().get());
+ if (!part.thought().orElse(false)) {
+ textBuilder.append(part.text().get());
+ }
} else if (part.functionCall().isPresent()) {
FunctionCall functionCall = part.functionCall().get();
toolCalls.add(
new AssistantMessage.ToolCall(
- functionCall
- .id()
- .orElseThrow(() -> new IllegalStateException("Function call ID is missing")),
+ functionCall.id().orElse(""),
"function",
functionCall
.name()
.orElseThrow(() -> new IllegalStateException("Function call name is missing")),
toJson(functionCall.args().orElse(Map.of()))));
part.thoughtSignature().ifPresent(thoughtSignatures::add);
+ } else {
+ partToMedia(part).ifPresent(mediaList::add);
}
}
String text = textBuilder.toString();
- if (toolCalls.isEmpty()) {
+ if (toolCalls.isEmpty() && mediaList.isEmpty()) {
return new AssistantMessage(text);
}
Map properties =
@@ -354,147 +578,219 @@ private AssistantMessage handleAssistantContent(Content content) {
.content(text)
.properties(properties)
.toolCalls(toolCalls)
+ .media(mediaList)
.build();
}
private SystemMessage handleSystemContent(Content content) {
StringBuilder textBuilder = new StringBuilder();
for (Part part : content.parts().orElse(List.of())) {
- if (part.text().isPresent()) {
- textBuilder.append(part.text().get());
- }
+ part.text().ifPresent(textBuilder::append);
}
return new SystemMessage(textBuilder.toString());
}
- /**
- * Converts a Spring AI ChatResponse to an ADK LlmResponse.
- *
- * @param chatResponse The Spring AI response to convert
- * @return An ADK LlmResponse
- */
- public LlmResponse toLlmResponse(ChatResponse chatResponse) {
- return toLlmResponse(chatResponse, false);
+ private Optional partToMedia(Part part) {
+ if (part.inlineData().isPresent()) {
+ Blob blob = part.inlineData().get();
+ if (blob.mimeType().isPresent() && blob.data().isPresent()) {
+ try {
+ MimeType mimeType = MimeType.valueOf(blob.mimeType().get());
+ ByteArrayResource resource = new ByteArrayResource(blob.data().get());
+ return Optional.of(new Media(mimeType, resource));
+ } catch (IllegalArgumentException e) {
+ logger.warn("Failed to process inline media part: {}", e.getClass().getName());
+ }
+ }
+ } else if (part.fileData().isPresent()) {
+ FileData fileData = part.fileData().get();
+ if (fileData.mimeType().isPresent() && fileData.fileUri().isPresent()) {
+ try {
+ MimeType mimeType = MimeType.valueOf(fileData.mimeType().get());
+ URI uri = URI.create(fileData.fileUri().get());
+ return Optional.of(new Media(mimeType, uri));
+ } catch (IllegalArgumentException e) {
+ logger.warn("Failed to process file media part: {}", e.getClass().getName());
+ }
+ }
+ }
+ return Optional.empty();
}
- /**
- * Converts a Spring AI ChatResponse to an ADK LlmResponse with streaming context.
- *
- * @param chatResponse The Spring AI response to convert
- * @param isStreaming Whether this is part of a streaming response
- * @return An ADK LlmResponse
- */
- public LlmResponse toLlmResponse(ChatResponse chatResponse, boolean isStreaming) {
- if (chatResponse == null || CollectionUtils.isEmpty(chatResponse.getResults())) {
- return LlmResponse.builder().build();
+ private Optional mediaToPart(@Nullable Media media) {
+ if (media == null || media.getMimeType() == null) {
+ return Optional.empty();
}
+ String mimeType = media.getMimeType().toString();
+ Object data = media.getData();
+ if (data instanceof byte[] bytes) {
+ return Optional.of(Part.fromBytes(bytes, mimeType));
+ }
+ if (data instanceof URI || data instanceof URL || data instanceof String) {
+ return Optional.of(Part.fromUri(data.toString(), mimeType));
+ }
+ logger.warn("Unsupported Spring AI Media data type: {}", data.getClass().getName());
+ return Optional.empty();
+ }
- Generation generation = chatResponse.getResult();
- AssistantMessage assistantMessage = generation.getOutput();
-
- Content content = convertAssistantMessageToContent(assistantMessage);
-
- // For streaming responses, check if this is a partial response
- boolean isPartial = isStreaming && isPartialResponse(assistantMessage);
- boolean isTurnComplete = !isStreaming || isTurnCompleteResponse(chatResponse);
-
- LlmResponse.Builder responseBuilder =
- LlmResponse.builder().content(content).partial(isPartial).turnComplete(isTurnComplete);
+ private Content convertUserMessageToContent(UserMessage userMessage) {
+ List parts = new ArrayList<>();
+ if (userMessage.getText() != null && !userMessage.getText().isEmpty()) {
+ parts.add(Part.fromText(userMessage.getText()));
+ }
+ if (userMessage.getMedia() != null) {
+ for (Media media : userMessage.getMedia()) {
+ mediaToPart(media).ifPresent(parts::add);
+ }
+ }
+ return Content.builder().role("user").parts(parts).build();
+ }
- if (chatResponse.getMetadata() != null
- && chatResponse.getMetadata().getUsage() != null
- && !(chatResponse.getMetadata().getUsage() instanceof EmptyUsage)) {
- Usage springUsage = chatResponse.getMetadata().getUsage();
+ private Content convertToolResponseMessageToContent(ToolResponseMessage toolResponseMessage) {
+ List parts = new ArrayList<>();
+ for (ToolResponseMessage.ToolResponse response : toolResponseMessage.getResponses()) {
+ FunctionResponse.Builder responseBuilder =
+ FunctionResponse.builder()
+ .name(response.name())
+ .response(ToolConverter.parseToolResult(response.responseData()));
+ if (response.id() != null && !response.id().isEmpty()) {
+ responseBuilder.id(response.id());
+ }
+ parts.add(Part.builder().functionResponse(responseBuilder.build()).build());
+ }
+ return Content.builder().role("user").parts(parts).build();
+ }
- GenerateContentResponseUsageMetadata adkUsage =
- GenerateContentResponseUsageMetadata.builder()
- .promptTokenCount(nullSafeInt(springUsage.getPromptTokens()))
- .candidatesTokenCount(nullSafeInt(springUsage.getCompletionTokens()))
- .totalTokenCount(nullSafeInt(springUsage.getTotalTokens()))
- .build();
- responseBuilder.usageMetadata(adkUsage);
+ /**
+ * Returns the candidate a generation belongs to, from the Spring AI Google GenAI ({@code
+ * candidateIndex}) or OpenAI ({@code index}) message metadata, or null if neither is set.
+ */
+ private static @Nullable Object candidateIndex(Generation generation) {
+ Map metadata =
+ generation.getOutput() != null ? generation.getOutput().getMetadata() : null;
+ if (metadata == null) {
+ return null;
}
- return responseBuilder.build();
+ return metadata.containsKey(CANDIDATE_INDEX_KEY)
+ ? metadata.get(CANDIDATE_INDEX_KEY)
+ : metadata.get(OPENAI_CHOICE_INDEX_KEY);
}
- private int nullSafeInt(Integer value) {
+ private int nullSafeInt(@Nullable Integer value) {
return value != null ? value.intValue() : 0;
}
- /** Determines if an assistant message represents a partial response in streaming. */
- private boolean isPartialResponse(AssistantMessage message) {
- // Check if message has incomplete content (e.g., ends mid-sentence, has pending tool calls)
- if (message.getText() != null && !message.getText().isEmpty()) {
- String text = message.getText().trim();
- // Simple heuristic: if text doesn't end with punctuation, it might be partial
- if (!text.endsWith(".")
- && !text.endsWith("!")
- && !text.endsWith("?")
- && !text.endsWith("\n")
- && message.getToolCalls().isEmpty()) {
- return true;
- }
+ /**
+ * Determines if the merged parts of a streamed candidate are partial: they carry no function call
+ * and their text does not end with sentence punctuation.
+ */
+ private static boolean isPartialResponse(List parts) {
+ if (parts.stream().anyMatch(part -> part.functionCall().isPresent())) {
+ return false;
}
+ String text =
+ parts.stream().map(part -> part.text().orElse("")).collect(Collectors.joining()).trim();
+ return !text.isEmpty() && !text.endsWith(".") && !text.endsWith("!") && !text.endsWith("?");
+ }
- // If there are tool calls, it's typically not partial (tool calls are discrete)
- return false;
+ /** Determines if a candidate's finish reason indicates the turn is complete. */
+ private boolean isTurnCompleteFinishReason(@Nullable String finishReason) {
+ if (finishReason == null) {
+ return true;
+ }
+ FinishReason.Known known = toAdkFinishReason(finishReason).knownEnum();
+ return known == FinishReason.Known.STOP
+ || known == FinishReason.Known.MAX_TOKENS
+ || known == FinishReason.Known.SAFETY
+ || known == FinishReason.Known.RECITATION;
}
- /** Determines if a chat response indicates the turn is complete. */
- private boolean isTurnCompleteResponse(ChatResponse response) {
- // In Spring AI, we can check the finish reason or other metadata
- // For now, assume turn is complete unless we have clear indication otherwise
- Generation generation = response.getResult();
- if (generation != null && generation.getMetadata() != null) {
- // Check if there's a finish reason indicating completion
- String finishReason = generation.getMetadata().getFinishReason();
- return finishReason == null
- || "stop".equals(finishReason)
- || "tool_calls".equals(finishReason);
- }
- return true;
+ private FinishReason toAdkFinishReason(String finishReason) {
+ return switch (finishReason.toLowerCase(Locale.ROOT)) {
+ case "stop",
+ "tool_calls",
+ "tool_use",
+ "end_turn",
+ "stop_sequence",
+ "pause_turn",
+ "function_call" ->
+ new FinishReason(FinishReason.Known.STOP);
+ case "length", "max_tokens" -> new FinishReason(FinishReason.Known.MAX_TOKENS);
+ case "content_filter", "safety" -> new FinishReason(FinishReason.Known.SAFETY);
+ case "recitation" -> new FinishReason(FinishReason.Known.RECITATION);
+ default -> {
+ FinishReason candidate = new FinishReason(finishReason.toUpperCase(Locale.ROOT));
+ yield candidate.knownEnum() != FinishReason.Known.FINISH_REASON_UNSPECIFIED
+ ? candidate
+ : new FinishReason(FinishReason.Known.OTHER);
+ }
+ };
}
private Content convertAssistantMessageToContent(AssistantMessage assistantMessage) {
List parts = new ArrayList<>();
- // Add text content
if (assistantMessage.getText() != null && !assistantMessage.getText().isEmpty()) {
- parts.add(Part.fromText(assistantMessage.getText()));
+ Map metadata = assistantMessage.getMetadata();
+ boolean isThought =
+ metadata != null
+ && (Boolean.TRUE.equals(metadata.get(IS_THOUGHT_KEY))
+ || metadata.containsKey(ANTHROPIC_THINKING_SIGNATURE_KEY));
+ Part textPart =
+ isThought
+ ? Part.builder().text(assistantMessage.getText()).thought(true).build()
+ : Part.fromText(assistantMessage.getText());
+ parts.add(textPart);
+ }
+
+ if (assistantMessage.getMedia() != null) {
+ for (Media media : assistantMessage.getMedia()) {
+ mediaToPart(media).ifPresent(parts::add);
+ }
}
- // Gemini thinking models return a thought signature per function-call part; keep them on the
- // parts so they can be replayed on the next turn (required for tool calling).
List thoughtSignatures = extractThoughtSignatures(assistantMessage);
int functionCallIndex = 0;
- // Add tool calls
for (AssistantMessage.ToolCall toolCall : assistantMessage.getToolCalls()) {
if ("function".equals(toolCall.type())) {
- try {
- Map args =
- objectMapper.readValue(toolCall.arguments(), MAP_TYPE_REFERENCE);
-
- // Create FunctionCall with ID, name, and args to preserve tool call ID
- FunctionCall functionCall =
- FunctionCall.builder().id(toolCall.id()).name(toolCall.name()).args(args).build();
-
- // Create Part with the FunctionCall (preserves ID), reattaching any thought signature.
- Part.Builder partBuilder = Part.builder().functionCall(functionCall);
- if (functionCallIndex < thoughtSignatures.size()) {
- partBuilder.thoughtSignature(thoughtSignatures.get(functionCallIndex));
- }
- parts.add(partBuilder.build());
- functionCallIndex++;
- } catch (JsonProcessingException e) {
- throw MessageConversionException.jsonParsingFailed("tool call arguments", e);
+ Map args = parseToolCallArguments(toolCall.arguments());
+
+ FunctionCall.Builder functionCallBuilder =
+ FunctionCall.builder().name(toolCall.name()).args(args);
+ if (toolCall.id() != null && !toolCall.id().isEmpty()) {
+ functionCallBuilder.id(toolCall.id());
+ }
+ FunctionCall functionCall = functionCallBuilder.build();
+
+ Part.Builder partBuilder = Part.builder().functionCall(functionCall);
+ if (functionCallIndex < thoughtSignatures.size()) {
+ partBuilder.thoughtSignature(thoughtSignatures.get(functionCallIndex));
}
+ parts.add(partBuilder.build());
+ functionCallIndex++;
}
}
return Content.builder().role("model").parts(parts).build();
}
+ private Map parseToolCallArguments(@Nullable String rawArguments) {
+ if (rawArguments == null || rawArguments.isBlank()) {
+ return ImmutableMap.of();
+ }
+ try {
+ Map args =
+ objectMapper
+ .readerFor(MAP_TYPE_REFERENCE)
+ .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
+ .readValue(rawArguments);
+ return args != null ? args : ImmutableMap.of();
+ } catch (JsonProcessingException e) {
+ throw MessageConversionException.jsonParsingFailed("tool call arguments", e);
+ }
+ }
+
@SuppressWarnings("unchecked")
private static List extractThoughtSignatures(AssistantMessage assistantMessage) {
Map metadata = assistantMessage.getMetadata();
diff --git a/contrib/spring-ai/src/main/java/com/google/adk/models/springai/SpringAiTool.java b/contrib/spring-ai/src/main/java/com/google/adk/models/springai/SpringAiTool.java
new file mode 100644
index 000000000..1269ceed8
--- /dev/null
+++ b/contrib/spring-ai/src/main/java/com/google/adk/models/springai/SpringAiTool.java
@@ -0,0 +1,141 @@
+/*
+ * 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;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.adk.tools.BaseTool;
+import com.google.adk.tools.ToolContext;
+import com.google.common.collect.ImmutableMap;
+import com.google.genai.types.FunctionDeclaration;
+import io.reactivex.rxjava3.core.Single;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import org.jspecify.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.definition.ToolDefinition;
+
+/**
+ * Exposes a Spring AI {@link ToolCallback} as an ADK {@link BaseTool}, so an existing Spring AI
+ * tool can be used by any ADK agent and model. Name, description, and input schema come from the
+ * callback's {@link ToolDefinition}; the schema is passed through as {@link
+ * FunctionDeclaration#parametersJsonSchema()}. The ADK {@link ToolContext} is passed to the
+ * callback under {@link ToolConverter#ADK_TOOL_CONTEXT_KEY}, and a {@code returnDirect} callback
+ * maps to ADK's {@code skipSummarization}.
+ */
+public final class SpringAiTool extends BaseTool {
+
+ private static final Logger logger = LoggerFactory.getLogger(SpringAiTool.class);
+ private static final ObjectMapper objectMapper = new ObjectMapper();
+ private static final ImmutableMap EMPTY_OBJECT_SCHEMA =
+ ImmutableMap.of("type", "object", "properties", ImmutableMap.of());
+
+ private final ToolCallback toolCallback;
+ private final FunctionDeclaration declaration;
+
+ /**
+ * Wraps {@code toolCallback} as an ADK tool.
+ *
+ * @param toolCallback The Spring AI tool to wrap
+ */
+ public SpringAiTool(ToolCallback toolCallback) {
+ this(toolCallback, definitionOf(toolCallback));
+ }
+
+ private SpringAiTool(ToolCallback toolCallback, ToolDefinition toolDefinition) {
+ super(
+ Objects.requireNonNull(toolDefinition.name(), "ToolDefinition name cannot be null"),
+ toolDefinition.description() != null ? toolDefinition.description() : "");
+ this.toolCallback = toolCallback;
+ // The wrapped ToolDefinition cannot change, so parse its schema once rather than per request.
+ FunctionDeclaration.Builder declarationBuilder =
+ FunctionDeclaration.builder().name(name()).description(description());
+ Map schema = parseInputSchema(name(), toolDefinition.inputSchema());
+ if (!schema.isEmpty()) {
+ declarationBuilder.parametersJsonSchema(schema);
+ }
+ this.declaration = declarationBuilder.build();
+ }
+
+ @Override
+ public Optional declaration() {
+ return Optional.of(declaration);
+ }
+
+ @Override
+ public Single> runAsync(
+ @Nullable Map args, @Nullable ToolContext toolContext) {
+ return Single.fromCallable(
+ () -> {
+ logger.debug("ADK calling Spring AI tool '{}'", name());
+ String toolInput;
+ try {
+ toolInput = objectMapper.writeValueAsString(args != null ? args : Map.of());
+ } catch (JsonProcessingException e) {
+ throw new IllegalStateException(
+ "Failed to serialize arguments for tool '" + name() + "'", e);
+ }
+
+ String rawResult = toolCallback.call(toolInput, springAiToolContextFrom(toolContext));
+
+ if (toolContext != null
+ && toolContext.actions() != null
+ && toolCallback.getToolMetadata() != null
+ && toolCallback.getToolMetadata().returnDirect()) {
+ toolContext.actions().setSkipSummarization(true);
+ }
+
+ return ToolConverter.parseToolResult(rawResult);
+ });
+ }
+
+ private static ToolDefinition definitionOf(ToolCallback toolCallback) {
+ Objects.requireNonNull(toolCallback, "toolCallback cannot be null");
+ return Objects.requireNonNull(
+ toolCallback.getToolDefinition(), "toolCallback.getToolDefinition() cannot be null");
+ }
+
+ /**
+ * Parses the callback's input schema; a null or blank schema means no parameters, and an
+ * unparseable one falls back to an empty object schema, as in ADK Kotlin, so one broken tool does
+ * not fail the agent.
+ */
+ private static Map parseInputSchema(String toolName, @Nullable String schema) {
+ if (schema == null || schema.isBlank()) {
+ return ImmutableMap.of();
+ }
+ try {
+ return ToolConverter.parseJsonSchemaMap(schema);
+ } catch (IllegalArgumentException e) {
+ logger.warn(
+ "Spring AI tool '{}' has an unparseable input schema; using an empty object schema",
+ toolName);
+ return EMPTY_OBJECT_SCHEMA;
+ }
+ }
+
+ private static org.springframework.ai.chat.model.ToolContext springAiToolContextFrom(
+ @Nullable ToolContext adkToolContext) {
+ if (adkToolContext == null) {
+ return new org.springframework.ai.chat.model.ToolContext(Map.of());
+ }
+ return new org.springframework.ai.chat.model.ToolContext(
+ Map.of(ToolConverter.ADK_TOOL_CONTEXT_KEY, adkToolContext));
+ }
+}
diff --git a/contrib/spring-ai/src/main/java/com/google/adk/models/springai/SpringAiToolset.java b/contrib/spring-ai/src/main/java/com/google/adk/models/springai/SpringAiToolset.java
new file mode 100644
index 000000000..d5c6dee58
--- /dev/null
+++ b/contrib/spring-ai/src/main/java/com/google/adk/models/springai/SpringAiToolset.java
@@ -0,0 +1,57 @@
+/*
+ * 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;
+
+import com.google.adk.agents.ReadonlyContext;
+import com.google.adk.tools.BaseTool;
+import com.google.adk.tools.BaseToolset;
+import io.reactivex.rxjava3.core.Flowable;
+import java.util.Arrays;
+import java.util.Objects;
+import org.jspecify.annotations.Nullable;
+import org.springframework.ai.tool.ToolCallbackProvider;
+
+/**
+ * Exposes every Spring AI tool from a {@link ToolCallbackProvider} as an ADK {@link BaseToolset},
+ * so a dependency-injected provider (for example a Spring bean) contributes its tools to an ADK
+ * agent. Each callback is wrapped with {@link SpringAiTool}, and the provider is queried on every
+ * {@link #getTools} call so tools it adds later are picked up.
+ */
+public final class SpringAiToolset implements BaseToolset {
+
+ private final ToolCallbackProvider toolCallbackProvider;
+
+ /**
+ * Creates a toolset backed by {@code toolCallbackProvider}.
+ *
+ * @param toolCallbackProvider The Spring AI provider whose tools to expose
+ */
+ public SpringAiToolset(ToolCallbackProvider toolCallbackProvider) {
+ this.toolCallbackProvider =
+ Objects.requireNonNull(toolCallbackProvider, "toolCallbackProvider cannot be null");
+ }
+
+ @Override
+ public Flowable getTools(@Nullable ReadonlyContext readonlyContext) {
+ return Flowable.defer(
+ () ->
+ Flowable.fromIterable(Arrays.asList(toolCallbackProvider.getToolCallbacks()))
+ .map(SpringAiTool::new));
+ }
+
+ @Override
+ public void close() {}
+}
diff --git a/contrib/spring-ai/src/main/java/com/google/adk/models/springai/ToolConverter.java b/contrib/spring-ai/src/main/java/com/google/adk/models/springai/ToolConverter.java
index 16de5d9a7..061477013 100644
--- a/contrib/spring-ai/src/main/java/com/google/adk/models/springai/ToolConverter.java
+++ b/contrib/spring-ai/src/main/java/com/google/adk/models/springai/ToolConverter.java
@@ -15,32 +15,57 @@
*/
package com.google.adk.models.springai;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.adk.tools.BaseTool;
+import com.google.adk.tools.BaseToolset;
import com.google.adk.tools.ToolContext;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.primitives.Ints;
import com.google.genai.types.FunctionDeclaration;
import com.google.genai.types.Schema;
import com.google.genai.types.Type;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
import java.util.Set;
import java.util.function.BiFunction;
+import java.util.stream.Stream;
+import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.ToolCallbackProvider;
+import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.function.FunctionToolCallback;
/**
- * Converts between ADK and Spring AI tool/function formats.
+ * Converts between ADK and Spring AI tool/function formats in both directions.
*
- * This converter handles the translation between ADK's BaseTool/FunctionDeclaration format and
- * Spring AI tool representations. This is a simplified initial version that focuses on basic schema
- * conversion and tool metadata handling.
+ *
This converter handles the translation between ADK's {@link BaseTool}/{@link
+ * FunctionDeclaration} format and Spring AI {@link ToolCallback} representations, including schema
+ * conversion and {@link ToolContext} propagation.
*/
public class ToolConverter {
private static final Logger logger = LoggerFactory.getLogger(ToolConverter.class);
+ // FAIL_ON_TRAILING_TOKENS keeps a text result like "42 is the answer" from parsing as 42.
+ private static final ObjectMapper objectMapper =
+ new ObjectMapper().enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
+ private static final TypeReference> MAP_TYPE_REFERENCE =
+ new TypeReference<>() {};
/** Key for passing an ADK {@link ToolContext} through Spring AI's tool context map. */
public static final String ADK_TOOL_CONTEXT_KEY = "adk_tool_context";
@@ -112,93 +137,416 @@ public Map convertSchemaToSpringAi(Schema schema) {
return springAiSchema;
}
- private String convertTypeToString(Type type) {
- return switch (type.knownEnum()) {
- case STRING -> "string";
- case NUMBER -> "number";
- case INTEGER -> "integer";
- case BOOLEAN -> "boolean";
- case ARRAY -> "array";
- case OBJECT -> "object";
- default -> "string"; // fallback
- };
+ /**
+ * Converts a Spring AI parameter schema map to an ADK {@link Schema}.
+ *
+ * Maps {@code type}, {@code description}, {@code properties}, {@code required}, {@code enum},
+ * and {@code items} recursively, defaulting missing array {@code items} to {@code STRING} and
+ * narrowing {@code required} to declared {@code properties}. As in Python ADK, a {@code $ref} is
+ * resolved as a JSON pointer into the root schema, or else by its last segment in the root {@code
+ * $defs} or {@code definitions}; keys next to the {@code $ref} override the referenced schema's,
+ * and a circular reference becomes an object schema.
+ *
+ * @param springAiSchema The Spring AI schema map to convert
+ * @return The converted ADK {@link Schema}
+ */
+ public Schema convertSchemaFromSpringAi(Map springAiSchema) {
+ Objects.requireNonNull(springAiSchema, "springAiSchema cannot be null");
+ return convertSchemaMap(springAiSchema, springAiSchema, ImmutableSet.of());
+ }
+
+ /**
+ * Parses a Spring AI JSON Schema string into an ADK {@link Schema}.
+ *
+ * @param jsonSchema The JSON Schema string (e.g. from {@link ToolDefinition#inputSchema()})
+ * @return The converted ADK {@link Schema}, or an empty object schema if {@code jsonSchema} is
+ * null or blank
+ * @throws IllegalArgumentException if {@code jsonSchema} is not valid JSON
+ */
+ public Schema convertSchemaFromSpringAi(@Nullable String jsonSchema) {
+ if (jsonSchema == null || jsonSchema.isBlank()) {
+ return Schema.builder().type(Type.Known.OBJECT).build();
+ }
+ Map schemaMap = parseJsonSchemaMap(jsonSchema);
+ if (schemaMap.isEmpty()) {
+ return Schema.builder().type(Type.Known.OBJECT).build();
+ }
+ return convertSchemaFromSpringAi(schemaMap);
+ }
+
+ private Schema convertSchemaMap(
+ Map springAiSchema,
+ Map rootSchema,
+ ImmutableSet expandingRefs) {
+ if (springAiSchema.isEmpty()) {
+ // An empty schema accepts anything; Python ADK maps it to an object schema.
+ return Schema.builder().type(Type.Known.OBJECT).build();
+ }
+ if (springAiSchema.get("$ref") instanceof String ref) {
+ if (expandingRefs.contains(ref)) {
+ return Schema.builder()
+ .type(Type.Known.OBJECT)
+ .description("Circular ref to " + ref.substring(ref.lastIndexOf('/') + 1))
+ .build();
+ }
+ Map referenced = resolveRef(ref, rootSchema);
+ if (referenced != null) {
+ Map resolved = new LinkedHashMap<>(referenced);
+ for (Map.Entry entry : springAiSchema.entrySet()) {
+ if (!entry.getKey().equals("$ref")) {
+ resolved.put(entry.getKey(), entry.getValue());
+ }
+ }
+ return convertSchemaMap(
+ resolved,
+ rootSchema,
+ ImmutableSet.builder().addAll(expandingRefs).add(ref).build());
+ }
+ }
+ Schema.Builder schemaBuilder = Schema.builder();
+
+ // As in Python ADK, a type list such as ["integer", "null"] maps to a nullable single type,
+ // preferring "array", and a "null"-only type maps to a nullable object.
+ List types = new ArrayList<>();
+ if (springAiSchema.get("type") instanceof String typeStr && !typeStr.isEmpty()) {
+ types.add(typeStr.toLowerCase(Locale.ROOT));
+ } else if (springAiSchema.get("type") instanceof List> typeList) {
+ typeList.stream()
+ .filter(String.class::isInstance)
+ .map(t -> ((String) t).toLowerCase(Locale.ROOT))
+ .forEach(types::add);
+ }
+ Type resolvedType = null;
+ if (!types.isEmpty()) {
+ if (types.remove("null")) {
+ schemaBuilder.nullable(true);
+ }
+ String typeStr =
+ types.contains("array") ? "array" : types.stream().findFirst().orElse("object");
+ resolvedType = convertStringToType(typeStr);
+ schemaBuilder.type(resolvedType);
+ }
+
+ if (springAiSchema.get("description") instanceof String desc) {
+ schemaBuilder.description(desc);
+ }
+
+ Map properties = null;
+ if (springAiSchema.get("properties") instanceof Map, ?> rawProperties) {
+ properties = new LinkedHashMap<>();
+ for (Map.Entry, ?> entry : rawProperties.entrySet()) {
+ if (entry.getKey() instanceof String key
+ && entry.getValue() instanceof Map, ?> propSchema) {
+ properties.put(key, convertSchemaMap(castToMap(propSchema), rootSchema, expandingRefs));
+ }
+ }
+ schemaBuilder.properties(properties);
+ }
+
+ if (springAiSchema.get("required") instanceof List> rawRequired && properties != null) {
+ List required = new ArrayList<>();
+ for (Object item : rawRequired) {
+ if (item instanceof String reqStr && properties.containsKey(reqStr)) {
+ required.add(reqStr);
+ }
+ }
+ if (!required.isEmpty()) {
+ schemaBuilder.required(required);
+ }
+ }
+
+ if (springAiSchema.get("enum") instanceof List> rawEnum) {
+ List enumValues = new ArrayList<>();
+ for (Object item : rawEnum) {
+ if (item != null) {
+ enumValues.add(String.valueOf(item));
+ }
+ }
+ if (!enumValues.isEmpty()) {
+ schemaBuilder.enum_(enumValues);
+ }
+ }
+
+ if (springAiSchema.get("items") instanceof Map, ?> rawItems) {
+ schemaBuilder.items(convertSchemaMap(castToMap(rawItems), rootSchema, expandingRefs));
+ } else if (resolvedType != null && resolvedType.knownEnum() == Type.Known.ARRAY) {
+ schemaBuilder.items(Schema.builder().type(Type.Known.STRING).build());
+ }
+
+ return schemaBuilder.build();
+ }
+
+ /**
+ * Resolves {@code ref} as a JSON pointer into {@code rootSchema}, or else by its last segment in
+ * the root {@code $defs} or {@code definitions}, or returns null if it names no object schema.
+ */
+ private static @Nullable Map resolveRef(
+ String ref, Map rootSchema) {
+ if (ref.equals("#")) {
+ return rootSchema;
+ }
+ if (ref.startsWith("#/")) {
+ Object current = rootSchema;
+ for (String segment : ref.substring(2).split("/", -1)) {
+ String token = segment.replace("~1", "/").replace("~0", "~");
+ Integer index = Ints.tryParse(token);
+ if (current instanceof Map, ?> map) {
+ current = map.get(token);
+ } else if (current instanceof List> list
+ && index != null
+ && index >= 0
+ && index < list.size()) {
+ current = list.get(index);
+ } else {
+ current = null;
+ }
+ }
+ if (current != null) {
+ return current instanceof Map, ?> map ? castToMap(map) : null;
+ }
+ }
+ String refKey = ref.substring(ref.lastIndexOf('/') + 1);
+ for (String defsKey : List.of("$defs", "definitions")) {
+ if (rootSchema.get(defsKey) instanceof Map, ?> defs
+ && defs.get(refKey) instanceof Map, ?> definition) {
+ return castToMap(definition);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Converts a single ADK {@link BaseTool} to a Spring AI {@link ToolCallback}.
+ *
+ * @param tool The ADK tool to convert
+ * @return An Optional containing the converted {@link ToolCallback}, or empty if the tool has no
+ * declaration
+ */
+ public Optional convertToSpringAiTool(BaseTool tool) {
+ Objects.requireNonNull(tool, "tool cannot be null");
+ if (tool.declaration().isEmpty()) {
+ return Optional.empty();
+ }
+ FunctionDeclaration declaration = tool.declaration().get();
+
+ BiFunction, org.springframework.ai.chat.model.ToolContext, String>
+ toolFunction =
+ (args, springAiToolContext) -> {
+ logger.debug("Spring AI calling tool '{}'", tool.name());
+ Map processedArgs = processArguments(args, declaration);
+ Map result =
+ tool.runAsync(processedArgs, adkToolContextFrom(springAiToolContext))
+ .blockingGet();
+ try {
+ return objectMapper.writeValueAsString(result);
+ } catch (JsonProcessingException e) {
+ throw new IllegalStateException(
+ "Failed to serialize result from tool '" + tool.name() + "'", e);
+ }
+ };
+
+ FunctionToolCallback.Builder callbackBuilder =
+ FunctionToolCallback.builder(tool.name(), toolFunction)
+ .description(tool.description())
+ .inputType(Map.class);
+
+ Optional schemaToSerialize =
+ declaration
+ .parameters()
+ .map(this::convertSchemaToSpringAi)
+ .or(declaration::parametersJsonSchema);
+ if (schemaToSerialize.isPresent()) {
+ try {
+ callbackBuilder.inputSchema(objectMapper.writeValueAsString(schemaToSerialize.get()));
+ } catch (JsonProcessingException e) {
+ logger.error("Error serializing schema to JSON for tool '{}'", tool.name(), e);
+ }
+ }
+
+ return Optional.of(callbackBuilder.build());
}
/**
- * Converts ADK tools to Spring AI ToolCallback format for tool calling.
+ * Converts ADK tools to Spring AI ToolCallback format for tool calling, skipping tools without a
+ * declaration.
*
* @param tools Map of ADK tools to convert
* @return List of Spring AI ToolCallback objects
*/
public List convertToSpringAiTools(Map tools) {
+ Objects.requireNonNull(tools, "tools cannot be null");
List toolCallbacks = new ArrayList<>();
-
for (BaseTool tool : tools.values()) {
- if (tool.declaration().isPresent()) {
- FunctionDeclaration declaration = tool.declaration().get();
+ convertToSpringAiTool(tool).ifPresent(toolCallbacks::add);
+ }
+ return toolCallbacks;
+ }
+
+ /**
+ * Converts a Spring AI {@link ToolCallback} to an ADK {@link BaseTool}.
+ *
+ * @param toolCallback The Spring AI tool callback to convert
+ * @return A {@link SpringAiTool} wrapping the callback
+ */
+ public BaseTool convertToAdkTool(ToolCallback toolCallback) {
+ return new SpringAiTool(toolCallback);
+ }
- BiFunction, org.springframework.ai.chat.model.ToolContext, String>
- toolFunction =
- (args, springAiToolContext) -> {
- logger.debug("Spring AI calling tool '{}'", tool.name());
- Map processedArgs = processArguments(args, declaration);
- Map result =
- tool.runAsync(processedArgs, adkToolContextFrom(springAiToolContext))
- .blockingGet();
- try {
- return new com.fasterxml.jackson.databind.ObjectMapper()
- .writeValueAsString(result);
- } catch (com.fasterxml.jackson.core.JsonProcessingException e) {
- throw new IllegalStateException(
- "Failed to serialize result from tool '" + tool.name() + "'", e);
- }
- };
-
- FunctionToolCallback.Builder callbackBuilder =
- FunctionToolCallback.builder(tool.name(), toolFunction).description(tool.description());
-
- // Convert ADK schema to Spring AI schema if available
- if (declaration.parameters().isPresent()) {
- // Use Map.class to indicate the input is an object/map
- callbackBuilder.inputType(Map.class);
-
- // Convert ADK schema to Spring AI JSON schema format
- Map springAiSchema =
- convertSchemaToSpringAi(declaration.parameters().get());
- logger.debug("Generated Spring AI schema for {}: {}", tool.name(), springAiSchema);
-
- // Provide the schema as JSON string using inputSchema method
- try {
- String schemaJson =
- new com.fasterxml.jackson.databind.ObjectMapper()
- .writeValueAsString(springAiSchema);
- callbackBuilder.inputSchema(schemaJson);
- logger.debug("Set input schema JSON: {}", schemaJson);
- } catch (Exception e) {
- logger.error("Error serializing schema to JSON: {}", e.getMessage(), e);
+ /**
+ * Converts a collection of Spring AI {@link ToolCallback}s to a list of ADK {@link BaseTool}s.
+ *
+ * @param toolCallbacks Collection of Spring AI tool callbacks to convert
+ * @return List of ADK {@link BaseTool} instances
+ */
+ public List convertToAdkTools(Collection extends ToolCallback> toolCallbacks) {
+ Objects.requireNonNull(toolCallbacks, "toolCallbacks cannot be null");
+ ImmutableList.Builder adkTools = ImmutableList.builder();
+ for (ToolCallback toolCallback : toolCallbacks) {
+ adkTools.add(convertToAdkTool(toolCallback));
+ }
+ return adkTools.build();
+ }
+
+ /**
+ * Converts a Spring AI {@link ToolCallbackProvider} to an ADK {@link BaseToolset} that queries
+ * the provider each time an agent lists its tools, so the toolset follows a provider whose tools
+ * change.
+ *
+ * @param toolCallbackProvider Provider of Spring AI tool callbacks
+ * @return A {@link SpringAiToolset} backed by {@code toolCallbackProvider}
+ */
+ public SpringAiToolset convertToAdkToolset(ToolCallbackProvider toolCallbackProvider) {
+ return new SpringAiToolset(toolCallbackProvider);
+ }
+
+ /**
+ * Parses a JSON schema string and normalizes it for Vertex AI (default array {@code items},
+ * declared-only {@code required}); returns an empty map for a JSON {@code null}.
+ *
+ * @throws IllegalArgumentException if {@code jsonSchema} is not a JSON object
+ */
+ static Map parseJsonSchemaMap(String jsonSchema) {
+ try {
+ Map schemaMap = objectMapper.readValue(jsonSchema, MAP_TYPE_REFERENCE);
+ if (schemaMap == null) {
+ return ImmutableMap.of();
+ }
+ normalizeSchemaMap(schemaMap);
+ return schemaMap;
+ } catch (JsonProcessingException e) {
+ throw new IllegalArgumentException("Failed to parse Spring AI JSON schema", e);
+ }
+ }
+
+ private static void normalizeSchemaMap(Map schema) {
+ Object type = schema.get("type");
+ boolean isArray =
+ "array".equalsIgnoreCase(String.valueOf(type))
+ || (type instanceof List> typeList
+ && typeList.stream().anyMatch(t -> "array".equalsIgnoreCase(String.valueOf(t))));
+ if (isArray && schema.get("items") == null) {
+ schema.put("items", Map.of("type", "string"));
+ }
+ Map, ?> rawProps =
+ schema.get("properties") instanceof Map, ?> props ? props : ImmutableMap.of();
+ for (Object propVal : rawProps.values()) {
+ if (propVal instanceof Map, ?> propMap) {
+ normalizeSchemaMap(castToMap(propMap));
+ }
+ }
+ // Properties that come in through $ref or a composition are not visible here; keep required.
+ boolean composed = Stream.of("$ref", "allOf", "anyOf", "oneOf").anyMatch(schema::containsKey);
+ if (!composed && schema.get("required") instanceof List> rawReq) {
+ ImmutableList narrowed =
+ rawReq.stream()
+ .filter(String.class::isInstance)
+ .map(String.class::cast)
+ .filter(rawProps::containsKey)
+ .collect(toImmutableList());
+ if (narrowed.isEmpty()) {
+ schema.remove("required");
+ } else {
+ schema.put("required", narrowed);
+ }
+ }
+ for (String subschemaKey : List.of("items", "additionalProperties")) {
+ if (schema.get(subschemaKey) instanceof Map, ?> subschema) {
+ normalizeSchemaMap(castToMap(subschema));
+ }
+ }
+ for (String defsKey : List.of("$defs", "definitions")) {
+ if (schema.get(defsKey) instanceof Map, ?> defsMap) {
+ for (Object defVal : defsMap.values()) {
+ if (defVal instanceof Map, ?> defSchema) {
+ normalizeSchemaMap(castToMap(defSchema));
}
- } else if (declaration.parametersJsonSchema().isPresent()) {
- callbackBuilder.inputType(Map.class);
- try {
- String schemaJson =
- new com.fasterxml.jackson.databind.ObjectMapper()
- .writeValueAsString(declaration.parametersJsonSchema().get());
- callbackBuilder.inputSchema(schemaJson);
- logger.debug("Set input schema JSON from parametersJsonSchema: {}", schemaJson);
- } catch (Exception e) {
- logger.error("Error serializing parametersJsonSchema to JSON: {}", e.getMessage(), e);
+ }
+ }
+ }
+ for (String unionKey : List.of("anyOf", "oneOf", "allOf")) {
+ if (schema.get(unionKey) instanceof List> members) {
+ for (Object member : members) {
+ if (member instanceof Map, ?> memberSchema) {
+ normalizeSchemaMap(castToMap(memberSchema));
}
}
-
- toolCallbacks.add(callbackBuilder.build());
}
}
+ }
- return toolCallbacks;
+ @SuppressWarnings("unchecked") // JSON object keys are strings.
+ static Map castToMap(Map, ?> rawMap) {
+ return (Map) rawMap;
}
- private ToolContext adkToolContextFrom(
- org.springframework.ai.chat.model.ToolContext springAiToolContext) {
+ private String convertTypeToString(Type type) {
+ return switch (type.knownEnum()) {
+ case STRING -> "string";
+ case NUMBER -> "number";
+ case INTEGER -> "integer";
+ case BOOLEAN -> "boolean";
+ case ARRAY -> "array";
+ case OBJECT -> "object";
+ default -> "string";
+ };
+ }
+
+ private Type convertStringToType(String lowerCaseType) {
+ return switch (lowerCaseType) {
+ case "string" -> new Type(Type.Known.STRING);
+ case "number" -> new Type(Type.Known.NUMBER);
+ case "integer" -> new Type(Type.Known.INTEGER);
+ case "boolean" -> new Type(Type.Known.BOOLEAN);
+ case "array" -> new Type(Type.Known.ARRAY);
+ case "object" -> new Type(Type.Known.OBJECT);
+ default -> new Type(Type.Known.STRING);
+ };
+ }
+
+ /**
+ * Parses a Spring AI tool result string into an ADK tool response map, wrapping non-object JSON
+ * and plain text under {@code "result"}.
+ */
+ static Map parseToolResult(@Nullable String rawResult) {
+ if (rawResult == null || rawResult.isBlank()) {
+ return Map.of();
+ }
+ try {
+ Object parsed = objectMapper.readValue(rawResult, Object.class);
+ if (parsed == null) {
+ return Map.of();
+ }
+ if (parsed instanceof Map, ?> map) {
+ return castToMap(map);
+ }
+ return Map.of("result", parsed);
+ } catch (JsonProcessingException e) {
+ return Map.of("result", rawResult);
+ }
+ }
+
+ private @Nullable ToolContext adkToolContextFrom(
+ org.springframework.ai.chat.model.@Nullable ToolContext springAiToolContext) {
if (springAiToolContext == null) {
return null;
}
@@ -222,27 +570,26 @@ private ToolContext adkToolContextFrom(
* different formats depending on the provider.
*/
private Map processArguments(
- Map args, FunctionDeclaration declaration) {
+ @Nullable Map args, FunctionDeclaration declaration) {
+ if (args == null) {
+ return Map.of();
+ }
if (declaration.parameters().isPresent()) {
var schema = declaration.parameters().get();
if (schema.properties().isPresent()) {
return normalizeArguments(args, schema.properties().get().keySet());
}
} else if (declaration.parametersJsonSchema().isPresent()) {
+ Map schemaMap;
try {
- @SuppressWarnings("unchecked")
- Map schemaMap =
- new com.fasterxml.jackson.databind.ObjectMapper()
- .convertValue(declaration.parametersJsonSchema().get(), Map.class);
- Object propertiesObj = schemaMap.get("properties");
- if (propertiesObj instanceof Map) {
- @SuppressWarnings("unchecked")
- Set expectedParams = ((Map) propertiesObj).keySet();
- return normalizeArguments(args, expectedParams);
- }
- } catch (Exception e) {
- logger.warn(
- "Error processing parametersJsonSchema for argument mapping: {}", e.getMessage());
+ schemaMap =
+ objectMapper.convertValue(declaration.parametersJsonSchema().get(), MAP_TYPE_REFERENCE);
+ } catch (IllegalArgumentException e) {
+ logger.warn("Error processing parametersJsonSchema for argument mapping", e);
+ return args;
+ }
+ if (schemaMap.get("properties") instanceof Map, ?> properties) {
+ return normalizeArguments(args, castToMap(properties).keySet());
}
}
diff --git a/contrib/spring-ai/src/test/java/com/google/adk/models/springai/ConfigMapperTest.java b/contrib/spring-ai/src/test/java/com/google/adk/models/springai/ConfigMapperTest.java
index 701e69d8f..c7c088e3b 100644
--- a/contrib/spring-ai/src/test/java/com/google/adk/models/springai/ConfigMapperTest.java
+++ b/contrib/spring-ai/src/test/java/com/google/adk/models/springai/ConfigMapperTest.java
@@ -16,14 +16,20 @@
package com.google.adk.models.springai;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.within;
+import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.genai.types.GenerateContentConfig;
+import com.google.genai.types.Schema;
+import com.google.genai.types.Type;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.prompt.ChatOptions;
+import org.springframework.ai.model.tool.StructuredOutputChatOptions;
class ConfigMapperTest {
@@ -95,8 +101,8 @@ void testToSpringAiChatOptionsWithPenalties() {
ChatOptions chatOptions = configMapper.toSpringAiChatOptions(Optional.of(config));
assertThat(chatOptions).isNotNull();
- // Penalties are not directly supported by Spring AI ChatOptions
- // The implementation should handle this gracefully
+ assertThat(chatOptions.getPresencePenalty()).isCloseTo(0.5, within(0.001));
+ assertThat(chatOptions.getFrequencyPenalty()).isCloseTo(0.3, within(0.001));
}
@Test
@@ -120,6 +126,8 @@ void testToSpringAiChatOptionsWithAllParameters() {
assertThat(chatOptions.getTopP()).isCloseTo(0.95, within(0.001));
assertThat(chatOptions.getTopK()).isCloseTo(50, within(1));
assertThat(chatOptions.getStopSequences()).containsExactly("STOP");
+ assertThat(chatOptions.getPresencePenalty()).isCloseTo(0.1, within(0.001));
+ assertThat(chatOptions.getFrequencyPenalty()).isCloseTo(0.2, within(0.001));
}
@Test
@@ -258,4 +266,318 @@ void testTypeConversions() {
assertThat(chatOptions.getTemperature()).isCloseTo(0.123456, within(0.000001));
assertThat(chatOptions.getTopP()).isCloseTo(0.987654, within(0.000001));
}
+
+ @Test
+ void toGenerateContentConfig_nullOptions_returnsNull() {
+ GenerateContentConfig fromNull = configMapper.toGenerateContentConfig(null);
+
+ assertThat(fromNull).isNull();
+ }
+
+ @Test
+ void toGenerateContentConfig_allParameters_mapsAllFields() {
+ ChatOptions chatOptions =
+ ChatOptions.builder()
+ .temperature(0.75)
+ .maxTokens(1500)
+ .topP(0.85)
+ .topK(40)
+ .stopSequences(List.of("STOP", "END"))
+ .presencePenalty(0.25)
+ .frequencyPenalty(0.5)
+ .build();
+
+ GenerateContentConfig config = configMapper.toGenerateContentConfig(chatOptions);
+
+ assertThat(config).isNotNull();
+ assertThat(config.temperature()).isPresent();
+ assertThat(config.temperature().get()).isCloseTo(0.75f, within(0.001f));
+ assertThat(config.maxOutputTokens()).contains(1500);
+ assertThat(config.topP()).isPresent();
+ assertThat(config.topP().get()).isCloseTo(0.85f, within(0.001f));
+ assertThat(config.topK()).isPresent();
+ assertThat(config.topK().get()).isCloseTo(40.0f, within(0.001f));
+ assertThat(config.stopSequences()).contains(List.of("STOP", "END"));
+ assertThat(config.presencePenalty()).isPresent();
+ assertThat(config.presencePenalty().get()).isCloseTo(0.25f, within(0.001f));
+ assertThat(config.frequencyPenalty()).isPresent();
+ assertThat(config.frequencyPenalty().get()).isCloseTo(0.5f, within(0.001f));
+ }
+
+ @Test
+ void toGenerateContentConfig_emptyStopSequences_omitsStopSequences() {
+ ChatOptions chatOptions = ChatOptions.builder().stopSequences(List.of()).build();
+
+ GenerateContentConfig config = configMapper.toGenerateContentConfig(chatOptions);
+
+ assertThat(config).isNotNull();
+ assertThat(config.stopSequences()).isEmpty();
+ }
+
+ @Test
+ void toGenerateContentConfig_structuredOutputSchema_roundTripsResponseJsonSchema() {
+ ChatOptions structuredOptions =
+ StructuredOutputChatOptions.builder()
+ .outputSchema(
+ "{\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"}},\"required\":[\"answer\"]}")
+ .temperature(0.25)
+ .build();
+
+ GenerateContentConfig config = configMapper.toGenerateContentConfig(structuredOptions);
+ ChatOptions roundTrippedOptions = configMapper.toSpringAiChatOptions(Optional.of(config));
+ GenerateContentConfig roundTrippedConfig =
+ configMapper.toGenerateContentConfig(roundTrippedOptions);
+
+ assertThat(config).isNotNull();
+ assertThat(config.temperature()).contains(0.25f);
+ assertThat(config.responseMimeType()).contains("application/json");
+ assertThat(config.responseSchema()).isEmpty();
+ assertThat(config.responseJsonSchema())
+ .contains(
+ Map.of(
+ "type",
+ "object",
+ "properties",
+ Map.of("answer", Map.of("type", "string")),
+ "required",
+ List.of("answer")));
+ assertThat(roundTrippedOptions).isInstanceOf(StructuredOutputChatOptions.class);
+ assertThat(roundTrippedConfig).isEqualTo(config);
+ }
+
+ @Test
+ void toGenerateContentConfig_nestedRefSchema_preservesDefsAndRefs() {
+ String nestedSchema =
+ """
+ {
+ "type": "object",
+ "$defs": {
+ "Address": {"type": "object", "properties": {"city": {"type": "string"}}}
+ },
+ "properties": {
+ "home": {"$ref": "#/$defs/Address"},
+ "nickname": {"anyOf": [{"type": "string"}, {"type": "null"}]}
+ },
+ "required": ["home"]
+ }
+ """;
+ ChatOptions structuredOptions =
+ StructuredOutputChatOptions.builder().outputSchema(nestedSchema).build();
+
+ GenerateContentConfig config = configMapper.toGenerateContentConfig(structuredOptions);
+
+ assertThat(config).isNotNull();
+ @SuppressWarnings("unchecked") // Test JSON object keys are strings.
+ Map responseJsonSchema =
+ (Map) config.responseJsonSchema().orElseThrow();
+ assertThat(responseJsonSchema).containsKey("$defs");
+ @SuppressWarnings("unchecked") // Test JSON object keys are strings.
+ Map properties = (Map) responseJsonSchema.get("properties");
+ assertThat(properties).containsEntry("home", Map.of("$ref", "#/$defs/Address"));
+ assertThat(properties).containsKey("nickname");
+ }
+
+ @Test
+ void toSpringAiChatOptions_responseSchema_mapsToStructuredOutputSchema() {
+ GenerateContentConfig config =
+ GenerateContentConfig.builder()
+ .responseSchema(
+ Schema.builder()
+ .type(Type.Known.OBJECT)
+ .properties(Map.of("answer", Schema.builder().type(Type.Known.STRING).build()))
+ .build())
+ .build();
+
+ ChatOptions chatOptions = configMapper.toSpringAiChatOptions(Optional.of(config));
+
+ assertThat(chatOptions).isInstanceOf(StructuredOutputChatOptions.class);
+ assertThat(((StructuredOutputChatOptions) chatOptions).getOutputSchema())
+ .contains("\"answer\"")
+ .contains("\"type\":\"object\"");
+ }
+
+ @Test
+ void toOpenAiStrictSchema_nestedSchema_requiresAllPropertiesAndStripsRefSiblings()
+ throws Exception {
+ String schema =
+ """
+ {
+ "type": "object",
+ "$defs": {
+ "Address": {"type": "object", "properties": {"city": {"type": "string"}}}
+ },
+ "properties": {
+ "name": {"type": "string"},
+ "home": {"$ref": "#/$defs/Address", "description": "Home address"},
+ "pets": {
+ "type": "array",
+ "items": {"type": "object", "properties": {"kind": {"type": "string"}}}
+ },
+ "nick": {"anyOf": [{"type": "object", "properties": {"v": {"type": "string"}}}]}
+ },
+ "required": ["name"]
+ }
+ """;
+
+ Map, ?> strict =
+ new ObjectMapper().readValue(ConfigMapper.toOpenAiStrictSchema(schema), Map.class);
+
+ assertThat(strict.get("additionalProperties")).isEqualTo(false);
+ assertThat(strict.get("required")).isEqualTo(List.of("home", "name", "nick", "pets"));
+ Map, ?> properties = (Map, ?>) strict.get("properties");
+ assertThat(properties.get("home")).isEqualTo(Map.of("$ref", "#/$defs/Address"));
+ Map, ?> address = (Map, ?>) ((Map, ?>) strict.get("$defs")).get("Address");
+ assertThat(address.get("additionalProperties")).isEqualTo(false);
+ assertThat(address.get("required")).isEqualTo(List.of("city"));
+ Map, ?> petItems = (Map, ?>) ((Map, ?>) properties.get("pets")).get("items");
+ assertThat(petItems.get("required")).isEqualTo(List.of("kind"));
+ Map, ?> nickOption =
+ (Map, ?>) ((List>) ((Map, ?>) properties.get("nick")).get("anyOf")).get(0);
+ assertThat(nickOption.get("additionalProperties")).isEqualTo(false);
+ }
+
+ @Test
+ void toOpenAiStrictSchema_upperCaseTypes_lowercasesAndEnforcesStrictObjects() throws Exception {
+ String schema =
+ """
+ {
+ "type": "OBJECT",
+ "properties": {
+ "city": {"type": "STRING"},
+ "nickname": {"type": ["STRING", "NULL"]}
+ }
+ }
+ """;
+
+ Map, ?> strict =
+ new ObjectMapper().readValue(ConfigMapper.toOpenAiStrictSchema(schema), Map.class);
+
+ assertThat(strict.get("type")).isEqualTo("object");
+ assertThat(strict.get("additionalProperties")).isEqualTo(false);
+ assertThat(strict.get("required")).isEqualTo(List.of("city", "nickname"));
+ Map, ?> properties = (Map, ?>) strict.get("properties");
+ assertThat(((Map, ?>) properties.get("city")).get("type")).isEqualTo("string");
+ assertThat(((Map, ?>) properties.get("nickname")).get("type"))
+ .isEqualTo(List.of("string", "null"));
+ }
+
+ @Test
+ void toGenerateContentConfig_blankOrInvalidOutputSchema_handlesEdgeCases() {
+ ChatOptions blankSchemaOptions =
+ StructuredOutputChatOptions.builder().outputSchema(" ").build();
+ ChatOptions invalidSchemaOptions =
+ StructuredOutputChatOptions.builder().outputSchema("{invalid").build();
+ ChatOptions nullSchemaOptions =
+ StructuredOutputChatOptions.builder().outputSchema("null").build();
+
+ GenerateContentConfig fromBlank = configMapper.toGenerateContentConfig(blankSchemaOptions);
+
+ assertThat(fromBlank).isNotNull();
+ assertThat(fromBlank.responseJsonSchema()).isEmpty();
+ assertThat(fromBlank.responseMimeType()).isEmpty();
+ assertThatThrownBy(() -> configMapper.toGenerateContentConfig(invalidSchemaOptions))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> configMapper.toGenerateContentConfig(nullSchemaOptions))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(
+ () ->
+ configMapper.toGenerateContentConfig(
+ StructuredOutputChatOptions.builder().outputSchema("{} trailing").build()))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void toSpringAiChatOptions_schemaObjectAsResponseJsonSchema_convertsToJsonSchema() {
+ GenerateContentConfig config =
+ GenerateContentConfig.builder()
+ .responseJsonSchema(Schema.builder().type(Type.Known.STRING).build())
+ .build();
+
+ ChatOptions chatOptions = configMapper.toSpringAiChatOptions(Optional.of(config));
+
+ assertThat(chatOptions).isInstanceOf(StructuredOutputChatOptions.class);
+ assertThat(((StructuredOutputChatOptions) chatOptions).getOutputSchema())
+ .isEqualTo("{\"type\":\"string\"}");
+ }
+
+ @Test
+ void isChatOptionsValid_validAndBoundaryOptions_returnsTrue() {
+ ChatOptions validOptions = ChatOptions.builder().temperature(0.8).topP(0.9).topK(32).build();
+ ChatOptions lowerBounds = ChatOptions.builder().temperature(0.0).topP(0.0).topK(1).build();
+ ChatOptions upperBounds = ChatOptions.builder().temperature(2.0).topP(1.0).topK(64).build();
+
+ boolean nullValid = configMapper.isChatOptionsValid(null);
+ boolean validValid = configMapper.isChatOptionsValid(validOptions);
+ boolean lowerValid = configMapper.isChatOptionsValid(lowerBounds);
+ boolean upperValid = configMapper.isChatOptionsValid(upperBounds);
+
+ assertThat(nullValid).isTrue();
+ assertThat(validValid).isTrue();
+ assertThat(lowerValid).isTrue();
+ assertThat(upperValid).isTrue();
+ }
+
+ @Test
+ void isChatOptionsValid_outOfBoundsOrNaN_returnsFalse() {
+ ChatOptions lowTemp = ChatOptions.builder().temperature(-0.1).build();
+ ChatOptions highTemp = ChatOptions.builder().temperature(2.5).build();
+ ChatOptions nanTemp = ChatOptions.builder().temperature(Double.NaN).build();
+ ChatOptions lowTopP = ChatOptions.builder().topP(-0.1).build();
+ ChatOptions highTopP = ChatOptions.builder().topP(1.1).build();
+ ChatOptions nanTopP = ChatOptions.builder().topP(Double.NaN).build();
+ ChatOptions lowTopK = ChatOptions.builder().topK(0).build();
+ ChatOptions highTopK = ChatOptions.builder().topK(65).build();
+ GenerateContentConfig nanConfig =
+ GenerateContentConfig.builder().temperature(Float.NaN).build();
+
+ boolean lowTempValid = configMapper.isChatOptionsValid(lowTemp);
+ boolean highTempValid = configMapper.isChatOptionsValid(highTemp);
+ boolean nanTempValid = configMapper.isChatOptionsValid(nanTemp);
+ boolean lowTopPValid = configMapper.isChatOptionsValid(lowTopP);
+ boolean highTopPValid = configMapper.isChatOptionsValid(highTopP);
+ boolean nanTopPValid = configMapper.isChatOptionsValid(nanTopP);
+ boolean lowTopKValid = configMapper.isChatOptionsValid(lowTopK);
+ boolean highTopKValid = configMapper.isChatOptionsValid(highTopK);
+ boolean nanConfigValid = configMapper.isConfigurationValid(Optional.of(nanConfig));
+
+ assertThat(lowTempValid).isFalse();
+ assertThat(highTempValid).isFalse();
+ assertThat(nanTempValid).isFalse();
+ assertThat(lowTopPValid).isFalse();
+ assertThat(highTopPValid).isFalse();
+ assertThat(nanTopPValid).isFalse();
+ assertThat(lowTopKValid).isFalse();
+ assertThat(highTopKValid).isFalse();
+ assertThat(nanConfigValid).isFalse();
+ }
+
+ @Test
+ void configConversion_roundTrip_preservesAllFields() {
+ GenerateContentConfig originalConfig =
+ GenerateContentConfig.builder()
+ .temperature(0.5f)
+ .maxOutputTokens(1024)
+ .topP(0.75f)
+ .topK(32f)
+ .stopSequences(List.of("DONE"))
+ .presencePenalty(0.25f)
+ .frequencyPenalty(0.5f)
+ .build();
+
+ ChatOptions springOptions = configMapper.toSpringAiChatOptions(Optional.of(originalConfig));
+ GenerateContentConfig roundTrippedConfig = configMapper.toGenerateContentConfig(springOptions);
+ ChatOptions roundTrippedOptions =
+ configMapper.toSpringAiChatOptions(Optional.of(roundTrippedConfig));
+
+ assertThat(roundTrippedConfig).isEqualTo(originalConfig);
+ assertThat(roundTrippedOptions.getTemperature()).isEqualTo(springOptions.getTemperature());
+ assertThat(roundTrippedOptions.getMaxTokens()).isEqualTo(springOptions.getMaxTokens());
+ assertThat(roundTrippedOptions.getTopP()).isEqualTo(springOptions.getTopP());
+ assertThat(roundTrippedOptions.getTopK()).isEqualTo(springOptions.getTopK());
+ assertThat(roundTrippedOptions.getStopSequences()).isEqualTo(springOptions.getStopSequences());
+ assertThat(roundTrippedOptions.getPresencePenalty())
+ .isEqualTo(springOptions.getPresencePenalty());
+ assertThat(roundTrippedOptions.getFrequencyPenalty())
+ .isEqualTo(springOptions.getFrequencyPenalty());
+ }
}
diff --git a/contrib/spring-ai/src/test/java/com/google/adk/models/springai/MessageConverterTest.java b/contrib/spring-ai/src/test/java/com/google/adk/models/springai/MessageConverterTest.java
index 310bf3d07..404e1bace 100644
--- a/contrib/spring-ai/src/test/java/com/google/adk/models/springai/MessageConverterTest.java
+++ b/contrib/spring-ai/src/test/java/com/google/adk/models/springai/MessageConverterTest.java
@@ -23,12 +23,15 @@
import com.google.adk.models.LlmResponse;
import com.google.adk.tools.BaseTool;
import com.google.genai.types.Content;
+import com.google.genai.types.FinishReason;
import com.google.genai.types.FunctionCall;
import com.google.genai.types.FunctionDeclaration;
import com.google.genai.types.FunctionResponse;
import com.google.genai.types.GenerateContentConfig;
+import com.google.genai.types.GenerateContentResponseUsageMetadata;
import com.google.genai.types.Part;
import com.google.genai.types.Schema;
+import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
@@ -37,17 +40,26 @@
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
+import org.springframework.ai.chat.messages.MessageType;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
+import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
import org.springframework.ai.chat.metadata.DefaultUsage;
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.content.Media;
+import org.springframework.ai.google.genai.GoogleGenAiChatOptions;
+import org.springframework.ai.model.tool.StructuredOutputChatOptions;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.openai.OpenAiChatOptions;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.function.FunctionToolCallback;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.util.MimeType;
class MessageConverterTest {
@@ -922,4 +934,491 @@ void testUserMessageWithMediaOnly() {
assertThat(userMessage.getText()).isEmpty();
assertThat(userMessage.getMedia()).hasSize(1);
}
+
+ @Test
+ void toAdkContent_userMessageWithTextAndMedia_convertsTextInlineAndFileMedia() {
+ byte[] inlineBytes = "png-bytes".getBytes(StandardCharsets.UTF_8);
+ Media inlineMedia =
+ new Media(MimeType.valueOf("image/png"), new ByteArrayResource(inlineBytes));
+ Media uriMedia = new Media(MimeType.valueOf("image/jpeg"), URI.create("gs://bucket/photo.jpg"));
+ UserMessage userMessage =
+ UserMessage.builder().text("Describe these").media(List.of(inlineMedia, uriMedia)).build();
+
+ Content content = messageConverter.toAdkContent(userMessage);
+
+ assertThat(content.role()).contains("user");
+ List parts = content.parts().orElseThrow();
+ assertThat(parts).hasSize(3);
+ assertThat(parts.get(0).text()).contains("Describe these");
+ assertThat(parts.get(1).inlineData().orElseThrow().mimeType()).contains("image/png");
+ assertThat(parts.get(1).inlineData().orElseThrow().data()).contains(inlineBytes);
+ assertThat(parts.get(2).fileData().orElseThrow().mimeType()).contains("image/jpeg");
+ assertThat(parts.get(2).fileData().orElseThrow().fileUri()).contains("gs://bucket/photo.jpg");
+ }
+
+ @Test
+ void toAdkContent_systemMessage_convertsToSystemContent() {
+ SystemMessage systemMessage = new SystemMessage("You are a concise assistant.");
+
+ Content content = messageConverter.toAdkContent(systemMessage);
+
+ assertThat(content.role()).contains("system");
+ assertThat(content.parts().orElseThrow()).hasSize(1);
+ assertThat(content.parts().orElseThrow().get(0).text())
+ .contains("You are a concise assistant.");
+ }
+
+ @Test
+ void toAdkContent_toolResponseMessage_convertsJsonObjectAndScalarResponses() {
+ ToolResponseMessage toolResponseMessage =
+ ToolResponseMessage.builder()
+ .responses(
+ List.of(
+ new ToolResponseMessage.ToolResponse(
+ "call_1", "get_weather", "{\"temp\":22,\"unit\":\"C\"}"),
+ new ToolResponseMessage.ToolResponse("call_2", "ping", "raw-text{"),
+ new ToolResponseMessage.ToolResponse("call_3", "answer", "42 is the answer")))
+ .build();
+
+ Content content = messageConverter.toAdkContent(toolResponseMessage);
+
+ assertThat(content.role()).contains("user");
+ List parts = content.parts().orElseThrow();
+ assertThat(parts).hasSize(3);
+ assertThat(parts.get(2).functionResponse().orElseThrow().response())
+ .contains(Map.of("result", "42 is the answer"));
+ FunctionResponse first = parts.get(0).functionResponse().orElseThrow();
+ assertThat(first.id()).contains("call_1");
+ assertThat(first.name()).contains("get_weather");
+ assertThat(first.response()).contains(Map.of("temp", 22, "unit", "C"));
+
+ FunctionResponse second = parts.get(1).functionResponse().orElseThrow();
+ assertThat(second.id()).contains("call_2");
+ assertThat(second.name()).contains("ping");
+ assertThat(second.response()).contains(Map.of("result", "raw-text{"));
+ }
+
+ @Test
+ void toAdkContent_assistantMessageWithMediaAndToolCalls_roundTripsWithToSpringAiMessages() {
+ byte[] signature = "sig-bytes".getBytes(StandardCharsets.UTF_8);
+ byte[] audioBytes = "audio-bytes".getBytes(StandardCharsets.UTF_8);
+ Media audioMedia = new Media(MimeType.valueOf("audio/wav"), new ByteArrayResource(audioBytes));
+ AssistantMessage assistantMessage =
+ AssistantMessage.builder()
+ .content("Checking now.")
+ .properties(Map.of("thoughtSignatures", List.of(signature)))
+ .toolCalls(
+ List.of(
+ new AssistantMessage.ToolCall(
+ "call_99", "function", "get_weather", "{\"location\":\"Paris\"}")))
+ .media(List.of(audioMedia))
+ .build();
+
+ Content adkContent = messageConverter.toAdkContent(assistantMessage);
+ List roundTrippedMessages = messageConverter.toSpringAiMessages(adkContent);
+
+ assertThat(adkContent.role()).contains("model");
+ List parts = adkContent.parts().orElseThrow();
+ assertThat(parts).hasSize(3);
+ assertThat(parts.get(0).text()).contains("Checking now.");
+ assertThat(parts.get(1).inlineData().orElseThrow().data()).contains(audioBytes);
+ assertThat(parts.get(2).functionCall().orElseThrow().id()).contains("call_99");
+ assertThat(parts.get(2).thoughtSignature()).contains(signature);
+
+ assertThat(roundTrippedMessages).hasSize(1);
+ AssistantMessage roundTripped = (AssistantMessage) roundTrippedMessages.get(0);
+ assertThat(roundTripped.getText()).isEqualTo("Checking now.");
+ assertThat(roundTripped.getMedia()).hasSize(1);
+ assertThat(roundTripped.getToolCalls()).hasSize(1);
+ }
+
+ @Test
+ void toLlmResponse_multipleCandidates_keepsOnlyFirstCandidate() {
+ Generation firstChoice =
+ new Generation(
+ AssistantMessage.builder().content("first").properties(Map.of("index", 0)).build());
+ Generation secondChoice =
+ new Generation(
+ AssistantMessage.builder().content("second").properties(Map.of("index", 1)).build());
+
+ LlmResponse response =
+ messageConverter.toLlmResponse(new ChatResponse(List.of(firstChoice, secondChoice)));
+
+ assertThat(response.content().orElseThrow().parts().orElseThrow())
+ .extracting(part -> part.text().orElseThrow())
+ .containsExactly("first");
+ }
+
+ @Test
+ void toLlmResponse_streamingThoughtThenToolCall_isCompleteNotPartial() {
+ Generation thoughtGen =
+ new Generation(
+ AssistantMessage.builder()
+ .content("thinking step")
+ .properties(Map.of("isThought", true))
+ .build());
+ Generation toolCallGen =
+ new Generation(
+ AssistantMessage.builder()
+ .content("")
+ .toolCalls(List.of(new AssistantMessage.ToolCall("id_1", "function", "ping", "{}")))
+ .build(),
+ ChatGenerationMetadata.builder().finishReason("STOP").build());
+
+ LlmResponse response =
+ messageConverter.toLlmResponse(new ChatResponse(List.of(thoughtGen, toolCallGen)), true);
+
+ assertThat(response.partial()).contains(false);
+ assertThat(response.turnComplete()).contains(true);
+ assertThat(response.finishReason()).contains(new FinishReason(FinishReason.Known.STOP));
+ }
+
+ @Test
+ void toLlmResponse_firstGenerationWithoutOutput_keepsLaterGenerationsOfFirstCandidate() {
+ Generation withoutOutput =
+ new Generation(null, ChatGenerationMetadata.builder().finishReason("SAFETY").build());
+ Generation withOutput =
+ new Generation(
+ AssistantMessage.builder().content("hello.").properties(Map.of("index", 0)).build());
+
+ LlmResponse response =
+ messageConverter.toLlmResponse(new ChatResponse(List.of(withoutOutput, withOutput)));
+
+ assertThat(response.content().orElseThrow().parts().orElseThrow())
+ .extracting(part -> part.text().orElseThrow())
+ .containsExactly("hello.");
+ assertThat(response.finishReason()).contains(new FinishReason(FinishReason.Known.SAFETY));
+ }
+
+ @Test
+ void toLlmResponse_streamingGenerationWithoutOutput_doesNotThrow() {
+ LlmResponse response =
+ messageConverter.toLlmResponse(new ChatResponse(List.of(new Generation(null))), true);
+
+ assertThat(response.partial()).contains(false);
+ assertThat(response.content().orElseThrow().parts().orElseThrow()).isEmpty();
+ }
+
+ @Test
+ void toAdkContent_toolCallArgumentsWithTrailingJson_throws() {
+ AssistantMessage trailingJson =
+ AssistantMessage.builder()
+ .content("")
+ .toolCalls(
+ List.of(
+ new AssistantMessage.ToolCall(
+ "id_2", "function", "ping", "{\"a\":1}{\"b\":2}")))
+ .build();
+
+ assertThrows(
+ MessageConversionException.class, () -> messageConverter.toAdkContent(trailingJson));
+ }
+
+ @Test
+ void toLlmPrompt_openAiDefaults_sendsStrictOutputSchema() throws Exception {
+ LlmRequest request =
+ LlmRequest.builder()
+ .config(
+ GenerateContentConfig.builder()
+ .responseJsonSchema(
+ Map.of(
+ "type",
+ "object",
+ "properties",
+ Map.of(
+ "city",
+ Map.of("type", "string"),
+ "tempC",
+ Map.of("type", "number")),
+ "required",
+ List.of("city")))
+ .build())
+ .build();
+
+ ChatOptions openAiOptions =
+ messageConverter
+ .toLlmPrompt(request, OpenAiChatOptions.builder().model("gpt-4o").build())
+ .getOptions();
+ ChatOptions googleOptions =
+ messageConverter
+ .toLlmPrompt(request, GoogleGenAiChatOptions.builder().model("gemini").build())
+ .getOptions();
+
+ Map, ?> strictSchema =
+ new ObjectMapper()
+ .readValue(((StructuredOutputChatOptions) openAiOptions).getOutputSchema(), Map.class);
+ Map, ?> googleSchema =
+ new ObjectMapper()
+ .readValue(((StructuredOutputChatOptions) googleOptions).getOutputSchema(), Map.class);
+ assertThat(strictSchema.get("additionalProperties")).isEqualTo(false);
+ assertThat(strictSchema.get("required")).isEqualTo(List.of("city", "tempC"));
+ assertThat(googleSchema.containsKey("additionalProperties")).isFalse();
+ assertThat(googleSchema.get("required")).isEqualTo(List.of("city"));
+ }
+
+ @Test
+ void toLlmResponse_nonStopFinishReason_setsErrorCodeAndMessage() {
+ Generation lengthGen =
+ new Generation(
+ new AssistantMessage("cut off"),
+ ChatGenerationMetadata.builder().finishReason("length").build());
+ Generation safetyGen =
+ new Generation(
+ new AssistantMessage(""),
+ ChatGenerationMetadata.builder().finishReason("SAFETY").build());
+ Generation stopGen =
+ new Generation(
+ new AssistantMessage("done."),
+ ChatGenerationMetadata.builder().finishReason("stop").build());
+
+ LlmResponse lengthResponse =
+ messageConverter.toLlmResponse(new ChatResponse(List.of(lengthGen)));
+ LlmResponse safetyResponse =
+ messageConverter.toLlmResponse(new ChatResponse(List.of(safetyGen)));
+ LlmResponse stopResponse = messageConverter.toLlmResponse(new ChatResponse(List.of(stopGen)));
+
+ assertThat(lengthResponse.errorCode())
+ .contains(new FinishReason(FinishReason.Known.MAX_TOKENS));
+ assertThat(lengthResponse.errorMessage()).contains("Maximum tokens reached");
+ assertThat(safetyResponse.errorCode()).contains(new FinishReason(FinishReason.Known.SAFETY));
+ assertThat(safetyResponse.errorMessage()).contains("Finished with SAFETY");
+ assertThat(stopResponse.errorCode()).isEmpty();
+ assertThat(stopResponse.errorMessage()).isEmpty();
+ }
+
+ @Test
+ void toAdkContent_unknownMessageType_throwsIllegalArgumentException() {
+ Message unknownMessage =
+ new Message() {
+ @Override
+ public MessageType getMessageType() {
+ return MessageType.SYSTEM;
+ }
+
+ @Override
+ public String getText() {
+ return "custom";
+ }
+
+ @Override
+ public Map getMetadata() {
+ return Map.of();
+ }
+ };
+
+ assertThrows(
+ IllegalArgumentException.class, () -> messageConverter.toAdkContent(unknownMessage));
+ }
+
+ @Test
+ void toAdkContent_anthropicThinkingAndEmptyToolCallId_marksThoughtAndOmitsId() {
+ AssistantMessage thinkingMessage =
+ AssistantMessage.builder()
+ .content("reasoning")
+ .properties(Map.of("signature", "sig"))
+ .build();
+ AssistantMessage toolCallMessage =
+ AssistantMessage.builder()
+ .content("")
+ .toolCalls(List.of(new AssistantMessage.ToolCall("", "function", "ping", "{}")))
+ .build();
+
+ Part thinkingPart = messageConverter.toAdkContent(thinkingMessage).parts().orElseThrow().get(0);
+ FunctionCall functionCall =
+ messageConverter
+ .toAdkContent(toolCallMessage)
+ .parts()
+ .orElseThrow()
+ .get(0)
+ .functionCall()
+ .orElseThrow();
+
+ assertThat(thinkingPart.thought()).contains(true);
+ assertThat(functionCall.id()).isEmpty();
+ assertThat(functionCall.name()).contains("ping");
+ }
+
+ @Test
+ void toLlmRequest_duplicateToolNames_throwsIllegalArgumentException() {
+ ToolCallback weather =
+ FunctionToolCallback.builder("get_weather", (Map args) -> "{}")
+ .description("Weather")
+ .inputType(Map.class)
+ .build();
+ Prompt prompt =
+ new Prompt(
+ List.of(new UserMessage("Weather?")),
+ ToolCallingChatOptions.builder().toolCallbacks(List.of(weather, weather)).build());
+
+ IllegalArgumentException error =
+ assertThrows(IllegalArgumentException.class, () -> messageConverter.toLlmRequest(prompt));
+
+ assertThat(error).hasMessageContaining("Duplicate tool name: get_weather");
+ }
+
+ @Test
+ void toLlmRequest_promptWithSystemMessagesToolsAndOptions_convertsAllFields() {
+ LlmRequest originalRequest =
+ LlmRequest.builder()
+ .config(GenerateContentConfig.builder().temperature(0.5f).maxOutputTokens(500).build())
+ .appendInstructions(List.of("System instruction"))
+ .contents(
+ List.of(
+ Content.builder()
+ .role("user")
+ .parts(List.of(Part.fromText("What is the weather in Paris?")))
+ .build()))
+ .tools(Map.of("get_weather", testTool()))
+ .build();
+ OpenAiChatOptions defaultOptions = OpenAiChatOptions.builder().model("gpt-4o").build();
+ Prompt prompt = messageConverter.toLlmPrompt(originalRequest, defaultOptions);
+
+ LlmRequest convertedRequest = messageConverter.toLlmRequest(prompt);
+
+ assertThat(convertedRequest.model()).contains("gpt-4o");
+ assertThat(convertedRequest.getSystemInstructions()).containsExactly("System instruction");
+ assertThat(convertedRequest.contents()).hasSize(1);
+ assertThat(convertedRequest.contents().get(0).role()).contains("user");
+ assertThat(convertedRequest.contents().get(0).parts().orElseThrow().get(0).text())
+ .contains("What is the weather in Paris?");
+ assertThat(convertedRequest.config()).isPresent();
+ assertThat(convertedRequest.config().get().temperature()).contains(0.5f);
+ assertThat(convertedRequest.config().get().maxOutputTokens()).contains(500);
+ assertThat(convertedRequest.tools()).containsOnlyKeys("get_weather");
+ assertThat(convertedRequest.config().get().tools().orElseThrow())
+ .singleElement()
+ .satisfies(
+ tool ->
+ assertThat(tool.functionDeclarations().orElseThrow())
+ .extracting(declaration -> declaration.name().orElseThrow())
+ .containsExactly("get_weather"));
+ }
+
+ @Test
+ void toChatResponse_llmResponseWithContentFinishReasonUsageAndModel_roundTrips() {
+ GenerateContentResponseUsageMetadata usage =
+ GenerateContentResponseUsageMetadata.builder()
+ .promptTokenCount(12)
+ .candidatesTokenCount(18)
+ .totalTokenCount(30)
+ .build();
+ LlmResponse originalResponse =
+ LlmResponse.builder()
+ .content(
+ Content.builder()
+ .role("model")
+ .parts(List.of(Part.fromText("Paris is sunny.")))
+ .build())
+ .finishReason(new FinishReason(FinishReason.Known.STOP))
+ .usageMetadata(usage)
+ .modelVersion("gemini-2.5-flash")
+ .build();
+
+ ChatResponse chatResponse = messageConverter.toChatResponse(originalResponse);
+ LlmResponse roundTripped = messageConverter.toLlmResponse(chatResponse);
+
+ assertThat(chatResponse.getResults()).hasSize(1);
+ assertThat(chatResponse.getResult().getOutput().getText()).isEqualTo("Paris is sunny.");
+ assertThat(chatResponse.getResult().getMetadata().getFinishReason()).isEqualTo("STOP");
+ assertThat(chatResponse.getMetadata().getModel()).isEqualTo("gemini-2.5-flash");
+ assertThat(chatResponse.getMetadata().getUsage().getTotalTokens()).isEqualTo(30);
+
+ assertThat(roundTripped.content()).isEqualTo(originalResponse.content());
+ assertThat(roundTripped.finishReason()).contains(new FinishReason(FinishReason.Known.STOP));
+ assertThat(roundTripped.modelVersion()).contains("gemini-2.5-flash");
+ assertThat(roundTripped.usageMetadata()).contains(usage);
+ }
+
+ @Test
+ void toChatResponse_nullOrFinishReasonOnlyResponse_preservesFinishReason() {
+ LlmResponse safetyBlockedResponse =
+ LlmResponse.builder().finishReason(new FinishReason(FinishReason.Known.SAFETY)).build();
+
+ ChatResponse fromNull = messageConverter.toChatResponse(null);
+ ChatResponse fromSafety = messageConverter.toChatResponse(safetyBlockedResponse);
+
+ assertThat(fromNull.getResults()).isEmpty();
+ assertThat(fromSafety.getResults()).hasSize(1);
+ assertThat(fromSafety.getResult().getMetadata().getFinishReason()).isEqualTo("SAFETY");
+ }
+
+ @Test
+ void toLlmResponse_finishReasonsAndMultiGeneration_mapsFinishReasonsAndMergesParts() {
+ Generation maxTokensGen =
+ new Generation(
+ new AssistantMessage("truncated"),
+ ChatGenerationMetadata.builder().finishReason("length").build());
+ Generation safetyGen =
+ new Generation(
+ new AssistantMessage(""),
+ ChatGenerationMetadata.builder().finishReason("content_filter").build());
+ Generation recitationGen =
+ new Generation(
+ new AssistantMessage(""),
+ ChatGenerationMetadata.builder().finishReason("recitation").build());
+ Generation unknownGen =
+ new Generation(
+ new AssistantMessage(""),
+ ChatGenerationMetadata.builder().finishReason("custom_provider_reason").build());
+ Generation thoughtGen =
+ new Generation(
+ AssistantMessage.builder()
+ .content("thinking step")
+ .properties(Map.of("isThought", true))
+ .build());
+ Generation toolCallGen =
+ new Generation(
+ AssistantMessage.builder()
+ .content("")
+ .toolCalls(List.of(new AssistantMessage.ToolCall("id_1", "function", "ping", "")))
+ .build(),
+ ChatGenerationMetadata.builder().finishReason("STOP").build());
+
+ LlmResponse maxTokensResp =
+ messageConverter.toLlmResponse(new ChatResponse(List.of(maxTokensGen)));
+ LlmResponse safetyResp = messageConverter.toLlmResponse(new ChatResponse(List.of(safetyGen)));
+ LlmResponse recitationResp =
+ messageConverter.toLlmResponse(new ChatResponse(List.of(recitationGen)));
+ LlmResponse unknownResp = messageConverter.toLlmResponse(new ChatResponse(List.of(unknownGen)));
+ LlmResponse mergedResp =
+ messageConverter.toLlmResponse(new ChatResponse(List.of(thoughtGen, toolCallGen)));
+
+ assertThat(maxTokensResp.finishReason())
+ .contains(new FinishReason(FinishReason.Known.MAX_TOKENS));
+ assertThat(safetyResp.finishReason()).contains(new FinishReason(FinishReason.Known.SAFETY));
+ assertThat(recitationResp.finishReason())
+ .contains(new FinishReason(FinishReason.Known.RECITATION));
+ assertThat(unknownResp.finishReason()).contains(new FinishReason(FinishReason.Known.OTHER));
+
+ List mergedParts = mergedResp.content().orElseThrow().parts().orElseThrow();
+ assertThat(mergedParts).hasSize(2);
+ assertThat(mergedParts.get(0).text()).contains("thinking step");
+ assertThat(mergedParts.get(0).thought()).contains(true);
+ assertThat(mergedParts.get(1).functionCall().orElseThrow().name()).contains("ping");
+ assertThat(mergedParts.get(1).functionCall().orElseThrow().args()).contains(Map.of());
+ assertThat(mergedResp.finishReason()).contains(new FinishReason(FinishReason.Known.STOP));
+ }
+
+ @Test
+ void toSpringAiMessages_assistantContentWithThoughtAndIdlessFunctionCall_filtersThought() {
+ Content assistantContent =
+ Content.builder()
+ .role("model")
+ .parts(
+ List.of(
+ Part.builder().text("internal thought").thought(true).build(),
+ Part.fromText("Visible answer."),
+ Part.builder()
+ .functionCall(FunctionCall.builder().name("ping").args(Map.of()).build())
+ .build()))
+ .build();
+
+ List messages = messageConverter.toSpringAiMessages(assistantContent);
+
+ assertThat(messages).hasSize(1);
+ AssistantMessage assistantMessage = (AssistantMessage) messages.get(0);
+ assertThat(assistantMessage.getText()).isEqualTo("Visible answer.");
+ assertThat(assistantMessage.getToolCalls()).hasSize(1);
+ assertThat(assistantMessage.getToolCalls().get(0).id()).isEmpty();
+ }
}
diff --git a/contrib/spring-ai/src/test/java/com/google/adk/models/springai/SpringAiToolTest.java b/contrib/spring-ai/src/test/java/com/google/adk/models/springai/SpringAiToolTest.java
new file mode 100644
index 000000000..0f2c07640
--- /dev/null
+++ b/contrib/spring-ai/src/test/java/com/google/adk/models/springai/SpringAiToolTest.java
@@ -0,0 +1,124 @@
+/*
+ * 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;
+
+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.when;
+
+import com.google.adk.events.EventActions;
+import com.google.adk.tools.BaseTool;
+import com.google.adk.tools.ToolContext;
+import com.google.genai.types.FunctionDeclaration;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.Test;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.definition.ToolDefinition;
+import org.springframework.ai.tool.metadata.ToolMetadata;
+
+class SpringAiToolTest {
+
+ private static ToolCallback callbackWithSchema(String name, String inputSchema) {
+ ToolCallback callback = mock(ToolCallback.class);
+ when(callback.getToolDefinition())
+ .thenReturn(
+ ToolDefinition.builder()
+ .name(name)
+ .description(name + " tool")
+ .inputSchema(inputSchema)
+ .build());
+ when(callback.getToolMetadata()).thenReturn(ToolMetadata.builder().build());
+ when(callback.call(any(), any())).thenReturn("{\"ok\":true}");
+ return callback;
+ }
+
+ @Test
+ void declaration_unparseableInputSchema_fallsBackToEmptyObjectSchema() {
+ SpringAiTool tool = new SpringAiTool(callbackWithSchema("broken", "{not json"));
+
+ FunctionDeclaration declaration = tool.declaration().orElseThrow();
+
+ assertThat(tool.name()).isEqualTo("broken");
+ assertThat(declaration.parametersJsonSchema())
+ .contains(Map.of("type", "object", "properties", Map.of()));
+ assertThat(tool.runAsync(Map.of(), null).blockingGet()).containsEntry("ok", true);
+ }
+
+ @Test
+ void getTools_oneCallbackWithUnparseableSchema_stillListsEveryTool() {
+ SpringAiToolset toolset =
+ new SpringAiToolset(
+ () ->
+ new ToolCallback[] {
+ callbackWithSchema("broken", "[1, 2]"),
+ callbackWithSchema("valid", "{\"type\":\"object\",\"properties\":{}}")
+ });
+
+ List tools = toolset.getTools(null).toList().blockingGet();
+
+ assertThat(tools).extracting(BaseTool::name).containsExactly("broken", "valid");
+ }
+
+ @Test
+ void declaration_blankInputSchema_hasNoParameters() {
+ ToolDefinition definition = mock(ToolDefinition.class);
+ when(definition.name()).thenReturn("ping");
+ when(definition.description()).thenReturn("");
+ when(definition.inputSchema()).thenReturn(" ");
+ ToolCallback callback = mock(ToolCallback.class);
+ when(callback.getToolDefinition()).thenReturn(definition);
+
+ FunctionDeclaration declaration = new SpringAiTool(callback).declaration().orElseThrow();
+
+ assertThat(declaration.parametersJsonSchema()).isEmpty();
+ assertThat(declaration.parameters()).isEmpty();
+ }
+
+ @Test
+ void runAsync_passesAdkToolContextAndMapsReturnDirect() {
+ AtomicReference captured =
+ new AtomicReference<>();
+ ToolCallback callback = mock(ToolCallback.class);
+ when(callback.getToolDefinition())
+ .thenReturn(
+ ToolDefinition.builder()
+ .name("direct")
+ .description("Direct tool")
+ .inputSchema("{\"type\":\"object\"}")
+ .build());
+ when(callback.getToolMetadata()).thenReturn(ToolMetadata.builder().returnDirect(true).build());
+ when(callback.call(any(), any()))
+ .thenAnswer(
+ invocation -> {
+ captured.set(invocation.getArgument(1));
+ return "42 is the answer";
+ });
+ ToolContext adkContext = mock(ToolContext.class);
+ EventActions actions = EventActions.builder().build();
+ when(adkContext.actions()).thenReturn(actions);
+
+ Map result =
+ new SpringAiTool(callback).runAsync(Map.of("k", "v"), adkContext).blockingGet();
+
+ assertThat(result).containsEntry("result", "42 is the answer");
+ assertThat(captured.get().getContext())
+ .containsEntry(ToolConverter.ADK_TOOL_CONTEXT_KEY, adkContext);
+ assertThat(actions.skipSummarization()).contains(true);
+ }
+}
diff --git a/contrib/spring-ai/src/test/java/com/google/adk/models/springai/SpringAiToolsetTest.java b/contrib/spring-ai/src/test/java/com/google/adk/models/springai/SpringAiToolsetTest.java
new file mode 100644
index 000000000..8c6e9c4c4
--- /dev/null
+++ b/contrib/spring-ai/src/test/java/com/google/adk/models/springai/SpringAiToolsetTest.java
@@ -0,0 +1,72 @@
+/*
+ * 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;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.google.adk.tools.BaseTool;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.Test;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.function.FunctionToolCallback;
+
+class SpringAiToolsetTest {
+
+ private static ToolCallback callback(String name, String result) {
+ return FunctionToolCallback.builder(name, (Map args) -> result)
+ .description(name + " tool")
+ .inputType(Map.class)
+ .build();
+ }
+
+ @Test
+ void getTools_providerChangesBetweenCalls_returnsCurrentTools() {
+ AtomicReference callbacks =
+ new AtomicReference<>(new ToolCallback[] {callback("get_weather", "{}")});
+ SpringAiToolset toolset = new SpringAiToolset(callbacks::get);
+
+ List before = toolset.getTools(null).toList().blockingGet();
+ callbacks.set(new ToolCallback[] {callback("get_weather", "{}"), callback("get_time", "{}")});
+ List after = toolset.getTools(null).toList().blockingGet();
+
+ assertThat(before).extracting(BaseTool::name).containsExactly("get_weather");
+ assertThat(after).extracting(BaseTool::name).containsExactly("get_weather", "get_time");
+ }
+
+ @Test
+ void getTools_convertedTool_runsSpringAiCallback() {
+ SpringAiToolset toolset =
+ new SpringAiToolset(
+ () -> new ToolCallback[] {callback("get_time", "{\"time\":\"12:00\"}")});
+
+ BaseTool tool = toolset.getTools(null).blockingFirst();
+ Map result = tool.runAsync(Map.of(), null).blockingGet();
+
+ assertThat(result).containsEntry("time", "12:00");
+ }
+
+ @Test
+ void getTools_emptyProvider_returnsNoTools() throws Exception {
+ SpringAiToolset toolset = new SpringAiToolset(() -> new ToolCallback[0]);
+
+ List tools = toolset.getTools(null).toList().blockingGet();
+ toolset.close();
+
+ assertThat(tools).isEmpty();
+ }
+}
diff --git a/contrib/spring-ai/src/test/java/com/google/adk/models/springai/ToolConverterTest.java b/contrib/spring-ai/src/test/java/com/google/adk/models/springai/ToolConverterTest.java
index 24ad7b93b..9c7debd6e 100644
--- a/contrib/spring-ai/src/test/java/com/google/adk/models/springai/ToolConverterTest.java
+++ b/contrib/spring-ai/src/test/java/com/google/adk/models/springai/ToolConverterTest.java
@@ -17,14 +17,19 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.adk.events.EventActions;
import com.google.adk.tools.BaseTool;
+import com.google.adk.tools.BaseToolset;
import com.google.adk.tools.ToolContext;
import com.google.genai.types.FunctionDeclaration;
import com.google.genai.types.Schema;
+import com.google.genai.types.Type;
import io.reactivex.rxjava3.core.Single;
import java.util.HashMap;
import java.util.List;
@@ -32,10 +37,17 @@
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.BiFunction;
+import java.util.function.Function;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.ToolCallbackProvider;
+import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.execution.ToolExecutionException;
+import org.springframework.ai.tool.function.FunctionToolCallback;
+import org.springframework.ai.tool.metadata.ToolMetadata;
+import org.springframework.ai.util.json.schema.JsonSchemaGenerator;
class ToolConverterTest {
@@ -417,7 +429,8 @@ void testToolCallbackRejectsWrongAdkContextType() {
.isInstanceOf(ToolExecutionException.class)
.hasRootCauseInstanceOf(IllegalArgumentException.class)
.hasRootCauseMessage(
- "Spring AI tool context entry 'adk_tool_context' must be an ADK ToolContext, but was java.lang.String");
+ "Spring AI tool context entry 'adk_tool_context' must be an ADK ToolContext, but was"
+ + " java.lang.String");
assertThat(tool.invocationCount()).isZero();
}
@@ -432,6 +445,741 @@ void testToolCallbackWrapsResultSerializationFailure() {
assertThat(tool.invocationCount()).isEqualTo(1);
}
+ @Test
+ void testConvertSchemaFromSpringAiAllTypes() {
+ assertThat(
+ toolConverter
+ .convertSchemaFromSpringAi(
+ Map.of("type", "string", "description", "A string parameter"))
+ .type())
+ .contains(new Type(Type.Known.STRING));
+ assertThat(toolConverter.convertSchemaFromSpringAi(Map.of("type", "number")).type())
+ .contains(new Type(Type.Known.NUMBER));
+ assertThat(toolConverter.convertSchemaFromSpringAi(Map.of("type", "integer")).type())
+ .contains(new Type(Type.Known.INTEGER));
+ assertThat(toolConverter.convertSchemaFromSpringAi(Map.of("type", "boolean")).type())
+ .contains(new Type(Type.Known.BOOLEAN));
+ assertThat(toolConverter.convertSchemaFromSpringAi(Map.of("type", "array")).type())
+ .contains(new Type(Type.Known.ARRAY));
+ assertThat(toolConverter.convertSchemaFromSpringAi(Map.of("type", "object")).type())
+ .contains(new Type(Type.Known.OBJECT));
+ assertThat(toolConverter.convertSchemaFromSpringAi(Map.of("type", "unknown_type")).type())
+ .contains(new Type(Type.Known.STRING));
+ }
+
+ @Test
+ void testConvertSchemaFromSpringAiWithObjectEnumAndItems() {
+ Map springAiSchema =
+ Map.of(
+ "type",
+ "object",
+ "description",
+ "Order request",
+ "properties",
+ Map.of(
+ "priority",
+ Map.of(
+ "type",
+ "string",
+ "description",
+ "Order priority",
+ "enum",
+ List.of("low", "high")),
+ "tags",
+ Map.of(
+ "type",
+ "array",
+ "items",
+ Map.of("type", "string", "enum", List.of("fragile", "gift"))),
+ "emptyEnum",
+ Map.of("type", "string", "enum", List.of()),
+ "code",
+ Map.of("type", "integer", "enum", List.of(1, 2, 3))),
+ "required",
+ List.of("priority"));
+
+ Schema schema = toolConverter.convertSchemaFromSpringAi(springAiSchema);
+
+ assertThat(schema.type()).contains(new Type(Type.Known.OBJECT));
+ assertThat(schema.description()).contains("Order request");
+ assertThat(schema.required()).contains(List.of("priority"));
+
+ Map properties = schema.properties().orElseThrow();
+ Schema priority = properties.get("priority");
+ assertThat(priority.type()).contains(new Type(Type.Known.STRING));
+ assertThat(priority.description()).contains("Order priority");
+ assertThat(priority.enum_()).contains(List.of("low", "high"));
+
+ Schema tags = properties.get("tags");
+ assertThat(tags.type()).contains(new Type(Type.Known.ARRAY));
+ assertThat(tags.items().orElseThrow().type()).contains(new Type(Type.Known.STRING));
+ assertThat(tags.items().orElseThrow().enum_()).contains(List.of("fragile", "gift"));
+
+ assertThat(properties.get("emptyEnum").enum_()).isEmpty();
+ assertThat(properties.get("code").enum_()).contains(List.of("1", "2", "3"));
+ }
+
+ @Test
+ void testConvertSchemaFromSpringAiDefaultsArrayItemsAndNarrowsRequired() {
+ Map springAiSchema =
+ Map.of(
+ "type",
+ "object",
+ "properties",
+ Map.of("tags", Map.of("type", "array"), "name", Map.of("type", "string")),
+ "required",
+ List.of("name", "undeclared_field"));
+
+ Schema schema = toolConverter.convertSchemaFromSpringAi(springAiSchema);
+
+ assertThat(schema.required()).contains(List.of("name"));
+ Schema tags = schema.properties().orElseThrow().get("tags");
+ assertThat(tags.type()).contains(new Type(Type.Known.ARRAY));
+ assertThat(tags.items()).isPresent();
+ assertThat(tags.items().get().type()).contains(new Type(Type.Known.STRING));
+ }
+
+ @Test
+ void testConvertSchemaFromSpringAiResolvesRefsFromSpringGeneratedSchema() {
+ String jsonSchema = JsonSchemaGenerator.generateForType(RouteRequest.class);
+
+ Map properties =
+ toolConverter.convertSchemaFromSpringAi(jsonSchema).properties().orElseThrow();
+
+ assertThat(properties.get("origin").type()).contains(new Type(Type.Known.OBJECT));
+ assertThat(properties.get("origin").properties().orElseThrow())
+ .containsOnlyKeys("city", "country");
+ assertThat(properties.get("destination").type()).contains(new Type(Type.Known.OBJECT));
+ assertThat(properties.get("destination").properties().orElseThrow())
+ .containsOnlyKeys("city", "country");
+ }
+
+ @Test
+ void testConvertSchemaFromSpringAiRefSiblingKeysOverrideDefinition() {
+ Map springAiSchema =
+ Map.of(
+ "definitions",
+ Map.of("Code", Map.of("type", "integer", "description", "Original")),
+ "$ref",
+ "#/definitions/Code",
+ "description",
+ "Override");
+
+ Schema schema = toolConverter.convertSchemaFromSpringAi(springAiSchema);
+
+ assertThat(schema.type()).contains(new Type(Type.Known.INTEGER));
+ assertThat(schema.description()).contains("Override");
+ }
+
+ @Test
+ void testConvertSchemaFromSpringAiCircularRefBecomesObject() {
+ Map springAiSchema =
+ Map.of(
+ "$defs",
+ Map.of(
+ "Node",
+ Map.of(
+ "type",
+ "object",
+ "properties",
+ Map.of("next", Map.of("$ref", "#/$defs/Node")))),
+ "$ref",
+ "#/$defs/Node");
+
+ Schema next =
+ toolConverter
+ .convertSchemaFromSpringAi(springAiSchema)
+ .properties()
+ .orElseThrow()
+ .get("next");
+
+ assertThat(next.type()).contains(new Type(Type.Known.OBJECT));
+ assertThat(next.description()).contains("Circular ref to Node");
+ }
+
+ @Test
+ void testConvertSchemaFromSpringAiPrefersDefsOverDefinitionsForLastSegmentLookup() {
+ Map springAiSchema =
+ Map.of(
+ "definitions",
+ Map.of("Value", Map.of("type", "string")),
+ "$defs",
+ Map.of("Value", Map.of("type", "integer")),
+ "$ref",
+ "#/components/Value");
+
+ Schema schema = toolConverter.convertSchemaFromSpringAi(springAiSchema);
+
+ assertThat(schema.type()).contains(new Type(Type.Known.INTEGER));
+ }
+
+ @Test
+ void testConvertSchemaFromSpringAiResolvesJsonPointerRefs() {
+ Map springAiSchema =
+ Map.of(
+ "type",
+ "object",
+ "properties",
+ Map.of(
+ "origin",
+ Map.of("type", "object", "properties", Map.of("city", Map.of("type", "string"))),
+ "destination",
+ Map.of("$ref", "#/properties/origin")));
+
+ Schema destination =
+ toolConverter
+ .convertSchemaFromSpringAi(springAiSchema)
+ .properties()
+ .orElseThrow()
+ .get("destination");
+
+ assertThat(destination.type()).contains(new Type(Type.Known.OBJECT));
+ assertThat(destination.properties().orElseThrow()).containsOnlyKeys("city");
+ }
+
+ @Test
+ void testConvertSchemaFromSpringAiResolvesRootEscapedAndIndexedPointers() {
+ Map springAiSchema =
+ Map.of(
+ "type",
+ "object",
+ "$defs",
+ Map.of("a/b", Map.of("type", "integer")),
+ "anyOf",
+ List.of(Map.of("type", "boolean")),
+ "properties",
+ Map.of(
+ "self", Map.of("$ref", "#"),
+ "escaped", Map.of("$ref", "#/$defs/a~1b"),
+ "indexed", Map.of("$ref", "#/anyOf/0"),
+ "anything", Map.of()));
+
+ Map properties =
+ toolConverter.convertSchemaFromSpringAi(springAiSchema).properties().orElseThrow();
+
+ Schema self = properties.get("self");
+ assertThat(self.type()).contains(new Type(Type.Known.OBJECT));
+ assertThat(self.properties().orElseThrow().get("self").description())
+ .contains("Circular ref to #");
+ assertThat(properties.get("escaped").type()).contains(new Type(Type.Known.INTEGER));
+ assertThat(properties.get("indexed").type()).contains(new Type(Type.Known.BOOLEAN));
+ assertThat(properties.get("anything").type()).contains(new Type(Type.Known.OBJECT));
+ }
+
+ @Test
+ void testUpperCaseArrayTypesGetDefaultItems() {
+ Map springAiSchema = Map.of("type", "ARRAY");
+ ToolCallback callback =
+ FunctionToolCallback.builder("upper_tool", (Map args) -> "{}")
+ .description("Upper-case types")
+ .inputType(Map.class)
+ .inputSchema("{\"type\":\"OBJECT\",\"properties\":{\"tags\":{\"type\":\"ARRAY\"}}}")
+ .build();
+
+ Schema schema = toolConverter.convertSchemaFromSpringAi(springAiSchema);
+ Map rawProps =
+ asMap(
+ asMap(
+ toolConverter
+ .convertToAdkTool(callback)
+ .declaration()
+ .orElseThrow()
+ .parametersJsonSchema()
+ .orElseThrow())
+ .get("properties"));
+
+ assertThat(schema.items().orElseThrow().type()).contains(new Type(Type.Known.STRING));
+ assertThat(asMap(rawProps.get("tags"))).containsEntry("items", Map.of("type", "string"));
+ }
+
+ @Test
+ void testConvertToAdkToolNormalizesAdditionalPropertiesSchema() {
+ String inputSchema =
+ """
+ {
+ "type": "object",
+ "properties": {
+ "labels": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "object",
+ "properties": {"values": {"type": "array"}},
+ "required": ["values", "ghost"]
+ }
+ }
+ }
+ }
+ """;
+ ToolCallback callback =
+ FunctionToolCallback.builder("label_tool", (Map args) -> "{}")
+ .description("Label tool")
+ .inputType(Map.class)
+ .inputSchema(inputSchema)
+ .build();
+
+ Map normalized =
+ asMap(
+ toolConverter
+ .convertToAdkTool(callback)
+ .declaration()
+ .orElseThrow()
+ .parametersJsonSchema()
+ .orElseThrow());
+
+ Map valueSchema =
+ asMap(asMap(asMap(normalized.get("properties")).get("labels")).get("additionalProperties"));
+ assertThat(valueSchema).containsEntry("required", List.of("values"));
+ assertThat(asMap(asMap(valueSchema.get("properties")).get("values")))
+ .containsEntry("items", Map.of("type", "string"));
+ }
+
+ @Test
+ void testConvertToAdkToolPreservesSpringGeneratedSchemaViaParametersJsonSchema() {
+ String jsonSchema = JsonSchemaGenerator.generateForType(RouteRequest.class);
+ ToolCallback callback =
+ FunctionToolCallback.builder("route", (Map args) -> "{}")
+ .description("Route tool")
+ .inputType(Map.class)
+ .inputSchema(jsonSchema)
+ .build();
+
+ BaseTool adkTool = toolConverter.convertToAdkTool(callback);
+
+ FunctionDeclaration declaration = adkTool.declaration().orElseThrow();
+ assertThat(declaration.parameters()).isEmpty();
+ assertThat(declaration.parametersJsonSchema()).isPresent();
+ Map rawSchema = asMap(declaration.parametersJsonSchema().get());
+ assertThat(rawSchema).containsEntry("type", "object");
+ assertThat(asMap(rawSchema.get("properties"))).containsKeys("origin", "destination");
+ }
+
+ @Test
+ void testConvertToAdkToolNormalizesMissingArrayItemsAndRequiredInParametersJsonSchema() {
+ String inputSchema =
+ """
+ {
+ "type": "object",
+ "$defs": {
+ "SubItem": {
+ "type": "object",
+ "properties": {
+ "nestedTags": {"type": "array"}
+ },
+ "required": ["nestedTags", "ghostField"]
+ }
+ },
+ "definitions": {
+ "Legacy": {"type": "array"}
+ },
+ "properties": {
+ "tags": {"type": "array"},
+ "choice": {
+ "anyOf": [
+ {"type": "array"},
+ {"type": "string"}
+ ]
+ },
+ "either": {"oneOf": [{"type": "array"}]},
+ "both": {"allOf": [{"type": "array"}]},
+ "noProps": {"type": "object", "required": ["x"]},
+ "allGhosts": {"type": "object", "properties": {}, "required": ["x"]},
+ "viaRef": {"$ref": "#/$defs/SubItem", "required": ["nestedTags"]}
+ },
+ "required": ["tags", "undeclared"]
+ }
+ """;
+ ToolCallback callback =
+ FunctionToolCallback.builder("tag_tool", (Map args) -> "{}")
+ .description("Tag tool")
+ .inputType(Map.class)
+ .inputSchema(inputSchema)
+ .build();
+
+ BaseTool adkTool = toolConverter.convertToAdkTool(callback);
+
+ Map normalized =
+ asMap(adkTool.declaration().orElseThrow().parametersJsonSchema().orElseThrow());
+ assertThat(normalized).containsEntry("required", List.of("tags"));
+ Map props = asMap(normalized.get("properties"));
+ assertThat(asMap(props.get("tags"))).containsEntry("items", Map.of("type", "string"));
+
+ List> anyOfList = (List>) asMap(props.get("choice")).get("anyOf");
+ assertThat(asMap(anyOfList.get(0))).containsEntry("items", Map.of("type", "string"));
+
+ Map subItem = asMap(asMap(normalized.get("$defs")).get("SubItem"));
+ assertThat(subItem).containsEntry("required", List.of("nestedTags"));
+ assertThat(asMap(asMap(subItem.get("properties")).get("nestedTags")))
+ .containsEntry("items", Map.of("type", "string"));
+
+ assertThat(asMap(asMap(normalized.get("definitions")).get("Legacy")))
+ .containsEntry("items", Map.of("type", "string"));
+ List> oneOfList = (List>) asMap(props.get("either")).get("oneOf");
+ assertThat(asMap(oneOfList.get(0))).containsEntry("items", Map.of("type", "string"));
+ List> allOfList = (List>) asMap(props.get("both")).get("allOf");
+ assertThat(asMap(allOfList.get(0))).containsEntry("items", Map.of("type", "string"));
+ assertThat(asMap(props.get("noProps"))).doesNotContainKey("required");
+ assertThat(asMap(props.get("allGhosts"))).doesNotContainKey("required");
+ assertThat(asMap(props.get("viaRef"))).containsEntry("required", List.of("nestedTags"));
+ }
+
+ @Test
+ void testNullableTypeListsDefaultArrayItemsAndMapToNullableSchema() {
+ String inputSchema =
+ """
+ {
+ "type": "object",
+ "properties": {
+ "tags": {"type": ["array", "null"]},
+ "count": {"type": ["integer", "null"]},
+ "mixed": {"type": ["string", "array"]},
+ "anything": {"type": "array", "items": true},
+ "nothing": {"type": "null"}
+ }
+ }
+ """;
+ ToolCallback callback =
+ FunctionToolCallback.builder("nullable_tool", (Map args) -> "{}")
+ .description("Nullable tool")
+ .inputType(Map.class)
+ .inputSchema(inputSchema)
+ .build();
+
+ Map rawProps =
+ asMap(
+ asMap(
+ toolConverter
+ .convertToAdkTool(callback)
+ .declaration()
+ .orElseThrow()
+ .parametersJsonSchema()
+ .orElseThrow())
+ .get("properties"));
+ Map props =
+ toolConverter.convertSchemaFromSpringAi(inputSchema).properties().orElseThrow();
+
+ assertThat(asMap(rawProps.get("tags"))).containsEntry("items", Map.of("type", "string"));
+ assertThat(props.get("tags").type()).contains(new Type(Type.Known.ARRAY));
+ assertThat(props.get("tags").nullable()).contains(true);
+ assertThat(props.get("tags").items().orElseThrow().type())
+ .contains(new Type(Type.Known.STRING));
+ assertThat(props.get("count").type()).contains(new Type(Type.Known.INTEGER));
+ assertThat(props.get("count").nullable()).contains(true);
+ assertThat(props.get("mixed").type()).contains(new Type(Type.Known.ARRAY));
+ assertThat(asMap(rawProps.get("anything"))).containsEntry("items", true);
+ assertThat(props.get("nothing").type()).contains(new Type(Type.Known.OBJECT));
+ assertThat(props.get("nothing").nullable()).contains(true);
+ }
+
+ @Test
+ void testConvertSchemaFromSpringAiJsonStringEdgeCases() {
+ assertThat(toolConverter.convertSchemaFromSpringAi((String) null).type())
+ .contains(new Type(Type.Known.OBJECT));
+ assertThat(toolConverter.convertSchemaFromSpringAi(" ").type())
+ .contains(new Type(Type.Known.OBJECT));
+ assertThat(toolConverter.convertSchemaFromSpringAi("null").type())
+ .contains(new Type(Type.Known.OBJECT));
+ assertThatThrownBy(() -> toolConverter.convertSchemaFromSpringAi("not valid json{"))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void testSchemaRoundTripSymmetry() {
+ Schema originalAdkSchema =
+ Schema.builder()
+ .type("OBJECT")
+ .description("Order filter")
+ .properties(
+ Map.of(
+ "query",
+ Schema.builder().type("STRING").description("Search query").build(),
+ "limit",
+ Schema.builder().type("INTEGER").build(),
+ "minScore",
+ Schema.builder().type("NUMBER").build(),
+ "active",
+ Schema.builder().type("BOOLEAN").build(),
+ "status",
+ Schema.builder().type("STRING").enum_("open", "closed").build(),
+ "items",
+ Schema.builder()
+ .type("ARRAY")
+ .items(
+ Schema.builder()
+ .type("OBJECT")
+ .properties(Map.of("id", Schema.builder().type("STRING").build()))
+ .required(List.of("id"))
+ .build())
+ .build()))
+ .required(List.of("query"))
+ .build();
+
+ Map springAiMap = toolConverter.convertSchemaToSpringAi(originalAdkSchema);
+ Schema roundTrippedAdkSchema = toolConverter.convertSchemaFromSpringAi(springAiMap);
+ Map roundTrippedSpringAiMap =
+ toolConverter.convertSchemaToSpringAi(roundTrippedAdkSchema);
+
+ assertThat(roundTrippedAdkSchema).isEqualTo(originalAdkSchema);
+ assertThat(roundTrippedSpringAiMap).isEqualTo(springAiMap);
+ }
+
+ @Test
+ void testConvertToSpringAiToolSingleAndZeroArgTool() {
+ FunctionDeclaration zeroArgDeclaration =
+ FunctionDeclaration.builder().name("ping").description("Ping tool").build();
+ BaseTool zeroArgTool =
+ new BaseTool("ping", "Ping tool") {
+ @Override
+ public Optional declaration() {
+ return Optional.of(zeroArgDeclaration);
+ }
+
+ @Override
+ public Single> runAsync(
+ Map args, ToolContext toolContext) {
+ return Single.just(Map.of("pong", true));
+ }
+ };
+ BaseTool undeclaredTool =
+ new BaseTool("undeclared", "No declaration") {
+ @Override
+ public Optional declaration() {
+ return Optional.empty();
+ }
+ };
+
+ Optional converted = toolConverter.convertToSpringAiTool(zeroArgTool);
+ assertThat(converted).isPresent();
+ assertThat(converted.get().getToolDefinition().name()).isEqualTo("ping");
+ assertThat(converted.get().call("{}")).contains("pong");
+
+ assertThat(toolConverter.convertToSpringAiTool(undeclaredTool)).isEmpty();
+ assertThat(
+ toolConverter.convertToSpringAiTools(
+ Map.of("ping", zeroArgTool, "undeclared", undeclaredTool)))
+ .hasSize(1);
+ }
+
+ @Test
+ void testConvertToAdkToolDeclarationAndExecution() {
+ AtomicReference> capturedArgs = new AtomicReference<>();
+ Function, String> fn =
+ args -> {
+ capturedArgs.set(args);
+ return "{\"temperature\":22,\"condition\":\"sunny\"}";
+ };
+ ToolCallback callback =
+ FunctionToolCallback.builder("get_weather", fn)
+ .description("Get weather for a city")
+ .inputType(Map.class)
+ .inputSchema(
+ "{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"description\":\"City"
+ + " name\"}},\"required\":[\"city\"]}")
+ .build();
+
+ BaseTool adkTool = toolConverter.convertToAdkTool(callback);
+
+ assertThat(adkTool.name()).isEqualTo("get_weather");
+ assertThat(adkTool.description()).isEqualTo("Get weather for a city");
+ assertThat(adkTool.declaration()).isPresent();
+
+ FunctionDeclaration declaration = adkTool.declaration().get();
+ assertThat(declaration.name()).contains("get_weather");
+ assertThat(declaration.description()).contains("Get weather for a city");
+ Map schemaMap = asMap(declaration.parametersJsonSchema().orElseThrow());
+ assertThat(schemaMap).containsEntry("type", "object");
+ assertThat(schemaMap).containsEntry("required", List.of("city"));
+ assertThat(asMap(asMap(schemaMap.get("properties")).get("city")))
+ .containsEntry("type", "string");
+
+ Map result = adkTool.runAsync(Map.of("city", "Paris"), null).blockingGet();
+
+ assertThat(capturedArgs.get()).containsEntry("city", "Paris");
+ assertThat(result).containsEntry("temperature", 22).containsEntry("condition", "sunny");
+ }
+
+ @Test
+ void testConvertToAdkToolPropagatesAdkToolContextAndReturnDirect() {
+ AtomicReference capturedContext =
+ new AtomicReference<>();
+ BiFunction, org.springframework.ai.chat.model.ToolContext, String> fn =
+ (args, ctx) -> {
+ capturedContext.set(ctx);
+ return "{\"ok\":true}";
+ };
+ ToolCallback callback =
+ FunctionToolCallback.builder("direct_tool", fn)
+ .description("Direct tool")
+ .inputType(Map.class)
+ .toolMetadata(ToolMetadata.builder().returnDirect(true).build())
+ .build();
+
+ ToolContext adkContext = mock(ToolContext.class);
+ EventActions actions = EventActions.builder().build();
+ when(adkContext.actions()).thenReturn(actions);
+
+ BaseTool adkTool = toolConverter.convertToAdkTool(callback);
+ Map result = adkTool.runAsync(Map.of("k", "v"), adkContext).blockingGet();
+
+ assertThat(result).containsEntry("ok", true);
+ assertThat(capturedContext.get()).isNotNull();
+ assertThat(capturedContext.get().getContext())
+ .containsEntry(ToolConverter.ADK_TOOL_CONTEXT_KEY, adkContext);
+ assertThat(actions.skipSummarization()).contains(true);
+ }
+
+ @Test
+ void testConvertToAdkToolWrapsNonObjectResults() {
+ ToolDefinition definition =
+ ToolDefinition.builder()
+ .name("scalar_tool")
+ .description("Returns non-object payloads")
+ .inputSchema("{\"type\":\"object\"}")
+ .build();
+
+ ToolCallback arrayCallback = mock(ToolCallback.class);
+ when(arrayCallback.getToolDefinition()).thenReturn(definition);
+ when(arrayCallback.call(any(), any())).thenReturn("[1,2,3]");
+ assertThat(toolConverter.convertToAdkTool(arrayCallback).runAsync(Map.of(), null).blockingGet())
+ .isEqualTo(Map.of("result", List.of(1, 2, 3)));
+
+ ToolCallback numberCallback = mock(ToolCallback.class);
+ when(numberCallback.getToolDefinition()).thenReturn(definition);
+ when(numberCallback.call(any(), any())).thenReturn("42");
+ assertThat(
+ toolConverter.convertToAdkTool(numberCallback).runAsync(Map.of(), null).blockingGet())
+ .isEqualTo(Map.of("result", 42));
+
+ ToolCallback rawTextCallback = mock(ToolCallback.class);
+ when(rawTextCallback.getToolDefinition()).thenReturn(definition);
+ when(rawTextCallback.call(any(), any())).thenReturn("not json{");
+ assertThat(
+ toolConverter.convertToAdkTool(rawTextCallback).runAsync(Map.of(), null).blockingGet())
+ .isEqualTo(Map.of("result", "not json{"));
+
+ ToolCallback numericPrefixCallback = mock(ToolCallback.class);
+ when(numericPrefixCallback.getToolDefinition()).thenReturn(definition);
+ when(numericPrefixCallback.call(any(), any())).thenReturn("42 is the answer");
+ assertThat(
+ toolConverter
+ .convertToAdkTool(numericPrefixCallback)
+ .runAsync(Map.of(), null)
+ .blockingGet())
+ .isEqualTo(Map.of("result", "42 is the answer"));
+
+ ToolCallback nullJsonCallback = mock(ToolCallback.class);
+ when(nullJsonCallback.getToolDefinition()).thenReturn(definition);
+ when(nullJsonCallback.call(any(), any())).thenReturn("null");
+ assertThat(
+ toolConverter.convertToAdkTool(nullJsonCallback).runAsync(Map.of(), null).blockingGet())
+ .isEmpty();
+
+ ToolCallback blankCallback = mock(ToolCallback.class);
+ when(blankCallback.getToolDefinition()).thenReturn(definition);
+ when(blankCallback.call(any(), any())).thenReturn(" ");
+ assertThat(toolConverter.convertToAdkTool(blankCallback).runAsync(Map.of(), null).blockingGet())
+ .isEmpty();
+ }
+
+ @Test
+ void testConvertToAdkToolsFromCollectionAndToolsetFromProvider() {
+ ToolCallback weather =
+ FunctionToolCallback.builder("get_weather", (Map args) -> "{\"temp\":20}")
+ .description("Weather")
+ .inputType(Map.class)
+ .build();
+ ToolCallback time =
+ FunctionToolCallback.builder(
+ "get_time", (Map args) -> "{\"time\":\"12:00\"}")
+ .description("Time")
+ .inputType(Map.class)
+ .build();
+
+ List fromList = toolConverter.convertToAdkTools(List.of(weather, time));
+ assertThat(fromList).extracting(BaseTool::name).containsExactly("get_weather", "get_time");
+
+ ToolCallbackProvider provider = ToolCallbackProvider.from(weather, time);
+ BaseToolset toolset = toolConverter.convertToAdkToolset(provider);
+ assertThat(toolset).isInstanceOf(SpringAiToolset.class);
+ assertThat(toolset.getTools(null).toList().blockingGet())
+ .extracting(BaseTool::name)
+ .containsExactly("get_weather", "get_time");
+ }
+
+ @Test
+ void testRoundTripAdkToolToSpringAiAndBackToAdk() {
+ RecordingTool originalTool = new RecordingTool(Map.of("forecast", "sunny", "tempC", 24));
+ ToolContext adkContext = mock(ToolContext.class);
+
+ List springCallbacks =
+ toolConverter.convertToSpringAiTools(Map.of(originalTool.name(), originalTool));
+ List roundTrippedTools = toolConverter.convertToAdkTools(springCallbacks);
+
+ assertThat(roundTrippedTools).hasSize(1);
+ BaseTool roundTrippedTool = roundTrippedTools.get(0);
+ assertThat(roundTrippedTool.name()).isEqualTo(originalTool.name());
+ assertThat(roundTrippedTool.description()).isEqualTo(originalTool.description());
+ assertThat(roundTrippedTool.declaration().orElseThrow().parametersJsonSchema())
+ .contains(
+ toolConverter.convertSchemaToSpringAi(
+ originalTool.declaration().orElseThrow().parameters().orElseThrow()));
+
+ Map result =
+ roundTrippedTool.runAsync(Map.of("location", "Paris"), adkContext).blockingGet();
+
+ assertThat(result).isEqualTo(Map.of("forecast", "sunny", "tempC", 24));
+ assertThat(originalTool.invocationCount()).isEqualTo(1);
+ assertThat(originalTool.arguments()).isEqualTo(Map.of("location", "Paris"));
+ assertThat(originalTool.context()).isSameAs(adkContext);
+ }
+
+ @Test
+ void testRoundTripSpringAiToolToAdkAndBackToSpringAi() throws Exception {
+ AtomicReference> capturedArgs = new AtomicReference<>();
+ AtomicReference capturedContext =
+ new AtomicReference<>();
+ BiFunction, org.springframework.ai.chat.model.ToolContext, String> fn =
+ (args, ctx) -> {
+ capturedArgs.set(args);
+ capturedContext.set(ctx);
+ return "{\"forecast\":\"rainy\"}";
+ };
+ ToolCallback originalCallback =
+ FunctionToolCallback.builder("get_weather", fn)
+ .description("Get weather for a location")
+ .inputType(Map.class)
+ .inputSchema(
+ "{\"type\":\"object\",\"properties\":{\"location\":{\"type\":\"string\"}},\"required\":[\"location\"]}")
+ .build();
+
+ BaseTool adkTool = toolConverter.convertToAdkTool(originalCallback);
+ List roundTrippedCallbacks =
+ toolConverter.convertToSpringAiTools(Map.of(adkTool.name(), adkTool));
+
+ assertThat(roundTrippedCallbacks).hasSize(1);
+ ToolCallback roundTrippedCallback = roundTrippedCallbacks.get(0);
+ assertThat(roundTrippedCallback.getToolDefinition().name()).isEqualTo("get_weather");
+ assertThat(roundTrippedCallback.getToolDefinition().description())
+ .isEqualTo("Get weather for a location");
+
+ ToolContext adkContext = mock(ToolContext.class);
+ org.springframework.ai.chat.model.ToolContext springContext =
+ new org.springframework.ai.chat.model.ToolContext(
+ Map.of(ToolConverter.ADK_TOOL_CONTEXT_KEY, adkContext));
+
+ String jsonResult = roundTrippedCallback.call("{\"location\":\"London\"}", springContext);
+ Map parsedResult =
+ new ObjectMapper().readValue(jsonResult, new TypeReference>() {});
+
+ assertThat(parsedResult).isEqualTo(Map.of("forecast", "rainy"));
+ assertThat(capturedArgs.get()).containsEntry("location", "London");
+ assertThat(capturedContext.get().getContext())
+ .containsEntry(ToolConverter.ADK_TOOL_CONTEXT_KEY, adkContext);
+ }
+
+ private record Location(String city, String country) {}
+
+ private record RouteRequest(Location origin, Location destination) {}
+
private static final class RecordingTool extends BaseTool {
private final FunctionDeclaration declaration;
private final Map result;
diff --git a/contrib/spring-ai/src/test/java/com/google/adk/models/springai/embeddings/EmbeddingConverterTest.java b/contrib/spring-ai/src/test/java/com/google/adk/models/springai/embeddings/EmbeddingConverterTest.java
index d401cec11..f906cdf26 100644
--- a/contrib/spring-ai/src/test/java/com/google/adk/models/springai/embeddings/EmbeddingConverterTest.java
+++ b/contrib/spring-ai/src/test/java/com/google/adk/models/springai/embeddings/EmbeddingConverterTest.java
@@ -20,11 +20,15 @@
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.google.adk.models.springai.EmbeddingConverter;
+import com.google.genai.types.ContentEmbedding;
+import com.google.genai.types.EmbedContentResponse;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.junit.jupiter.api.Test;
+import org.springframework.ai.embedding.Embedding;
import org.springframework.ai.embedding.EmbeddingRequest;
+import org.springframework.ai.embedding.EmbeddingResponse;
class EmbeddingConverterTest {
@@ -48,23 +52,34 @@ void testCreateRequestMultipleTexts() {
@Test
void testExtractEmbeddings() {
- // Skip this test due to Mockito limitations with final classes
- // This will be tested with real integration tests
- assertThat(true).isTrue(); // Placeholder assertion
+ float[] vec1 = {0.1f, 0.2f};
+ float[] vec2 = {0.3f, 0.4f};
+ EmbeddingResponse response = EmbeddingConverter.toEmbeddingResponse(List.of(vec1, vec2));
+
+ List extracted = EmbeddingConverter.extractEmbeddings(response);
+
+ assertThat(extracted).hasSize(2);
+ assertThat(extracted.get(0)).containsExactly(0.1f, 0.2f);
+ assertThat(extracted.get(1)).containsExactly(0.3f, 0.4f);
}
@Test
void testExtractFirstEmbedding() {
- // Skip this test due to Mockito limitations with final classes
- // This will be tested with real integration tests
- assertThat(true).isTrue(); // Placeholder assertion
+ float[] vec1 = {0.5f, 0.6f, 0.7f};
+ EmbeddingResponse response = EmbeddingConverter.toEmbeddingResponse(vec1);
+
+ float[] first = EmbeddingConverter.extractFirstEmbedding(response);
+
+ assertThat(first).containsExactly(0.5f, 0.6f, 0.7f);
}
@Test
void testExtractFirstEmbeddingEmptyResponse() {
- // Skip this test due to Mockito limitations with final classes
- // This will be tested with real integration tests
- assertThat(true).isTrue(); // Placeholder assertion
+ EmbeddingResponse emptyResponse = new EmbeddingResponse(List.of());
+
+ float[] first = EmbeddingConverter.extractFirstEmbedding(emptyResponse);
+
+ assertThat(first).isNull();
}
@Test
@@ -241,4 +256,77 @@ void testToFloatArray() {
assertThat(floatArray[1]).isCloseTo(2.5f, within(0.0001f));
assertThat(floatArray[2]).isCloseTo(3.7f, within(0.0001f));
}
+
+ @Test
+ void extractTexts_fromEmbeddingRequest_returnsInputInstructions() {
+ EmbeddingRequest request = EmbeddingConverter.createRequest(List.of("alpha", "beta"));
+
+ List extracted = EmbeddingConverter.extractTexts(request);
+
+ assertThat(extracted).containsExactly("alpha", "beta");
+ }
+
+ @Test
+ void toEmbedding_copiesInputVectorAndPreservesIndex() {
+ float[] original = {0.25f, 0.75f};
+
+ Embedding embedding = EmbeddingConverter.toEmbedding(original, 3);
+ original[0] = 99.0f;
+
+ assertThat(embedding.getIndex()).isEqualTo(3);
+ assertThat(embedding.getOutput()).containsExactly(0.25f, 0.75f);
+ }
+
+ @Test
+ void contentEmbeddingConversion_roundTrip_preservesValues() {
+ float[] vector = {0.1f, -0.2f, 0.3f};
+
+ ContentEmbedding contentEmbedding = EmbeddingConverter.toContentEmbedding(vector);
+ float[] roundTrippedVector = EmbeddingConverter.extractEmbedding(contentEmbedding);
+ Embedding springEmbedding = EmbeddingConverter.toEmbedding(contentEmbedding, 2);
+ ContentEmbedding fromSpringEmbedding = EmbeddingConverter.toContentEmbedding(springEmbedding);
+
+ assertThat(contentEmbedding.values()).contains(List.of(0.1f, -0.2f, 0.3f));
+ assertThat(roundTrippedVector).containsExactly(0.1f, -0.2f, 0.3f);
+ assertThat(springEmbedding.getIndex()).isEqualTo(2);
+ assertThat(springEmbedding.getOutput()).containsExactly(0.1f, -0.2f, 0.3f);
+ assertThat(fromSpringEmbedding).isEqualTo(contentEmbedding);
+ }
+
+ @Test
+ void embedContentResponseConversion_roundTripAndOutOfOrderIndices_preservesBatchOrder() {
+ float[] vec0 = {1.0f, 2.0f};
+ float[] vec1 = {3.0f, 4.0f};
+ EmbeddingResponse outOfOrderSpringResponse =
+ new EmbeddingResponse(List.of(new Embedding(vec1, 1), new Embedding(vec0, 0)));
+
+ EmbedContentResponse adkResponse =
+ EmbeddingConverter.toEmbedContentResponse(outOfOrderSpringResponse);
+ EmbeddingResponse roundTrippedSpring = EmbeddingConverter.fromEmbedContentResponse(adkResponse);
+ List roundTrippedVectors = EmbeddingConverter.extractEmbeddings(roundTrippedSpring);
+
+ assertThat(adkResponse.embeddings()).isPresent();
+ assertThat(adkResponse.embeddings().get()).hasSize(2);
+ assertThat(roundTrippedSpring.getResults()).hasSize(2);
+ assertThat(roundTrippedSpring.getResults().get(0).getIndex()).isEqualTo(0);
+ assertThat(roundTrippedSpring.getResults().get(1).getIndex()).isEqualTo(1);
+ assertThat(roundTrippedVectors.get(0)).containsExactly(1.0f, 2.0f);
+ assertThat(roundTrippedVectors.get(1)).containsExactly(3.0f, 4.0f);
+ }
+
+ @Test
+ void embeddingConversions_emptyOrAbsentFields_returnEmptyOutputs() {
+ EmbeddingRequest nullInstructionsRequest = new EmbeddingRequest(null, null);
+ ContentEmbedding emptyContentEmbedding = ContentEmbedding.builder().build();
+ EmbedContentResponse emptyAdkResponse = EmbedContentResponse.builder().build();
+
+ List extractedTexts = EmbeddingConverter.extractTexts(nullInstructionsRequest);
+ float[] extractedVector = EmbeddingConverter.extractEmbedding(emptyContentEmbedding);
+ EmbeddingResponse convertedResponse =
+ EmbeddingConverter.fromEmbedContentResponse(emptyAdkResponse);
+
+ assertThat(extractedTexts).isEmpty();
+ assertThat(extractedVector).isEmpty();
+ assertThat(convertedResponse.getResults()).isEmpty();
+ }
}