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 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(); + } }