Skip to content

Commit d950c2e

Browse files
Rafal Glowinskicopybara-github
authored andcommitted
feat: add bidirectional conversion between ADK and Spring AI
PiperOrigin-RevId: 1005531621
1 parent 1917ec1 commit d950c2e

12 files changed

Lines changed: 3326 additions & 363 deletions

File tree

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

Lines changed: 224 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -15,72 +15,146 @@
1515
*/
1616
package com.google.adk.models.springai;
1717

18+
import static com.google.common.collect.ImmutableList.toImmutableList;
19+
20+
import com.fasterxml.jackson.core.JsonProcessingException;
21+
import com.fasterxml.jackson.core.type.TypeReference;
22+
import com.fasterxml.jackson.databind.DeserializationFeature;
23+
import com.google.adk.JsonBaseModel;
24+
import com.google.common.collect.ImmutableList;
1825
import com.google.genai.types.GenerateContentConfig;
26+
import com.google.genai.types.Schema;
27+
import java.util.ArrayList;
28+
import java.util.List;
29+
import java.util.Locale;
30+
import java.util.Map;
31+
import java.util.Objects;
1932
import java.util.Optional;
33+
import java.util.Set;
34+
import org.jspecify.annotations.Nullable;
2035
import org.springframework.ai.chat.prompt.ChatOptions;
36+
import org.springframework.ai.model.tool.StructuredOutputChatOptions;
2137

2238
/**
23-
* Maps ADK GenerateContentConfig to Spring AI ChatOptions.
24-
*
25-
* <p>This mapper handles the translation between ADK's GenerateContentConfig and Spring AI's
26-
* ChatOptions, enabling configuration parameters like temperature, max tokens, and stop sequences
27-
* to be passed through to Spring AI models.
39+
* Maps between ADK {@link GenerateContentConfig} and Spring AI {@link ChatOptions} in both
40+
* directions.
2841
*/
2942
public class ConfigMapper {
3043

44+
private static final TypeReference<Map<String, Object>> MAP_TYPE_REFERENCE =
45+
new TypeReference<>() {};
46+
private static final String JSON_MIME_TYPE = "application/json";
47+
private final ToolConverter toolConverter = new ToolConverter();
48+
3149
/**
3250
* Converts ADK GenerateContentConfig to Spring AI ChatOptions.
3351
*
52+
* <p>When {@link GenerateContentConfig#responseJsonSchema()} or {@link
53+
* GenerateContentConfig#responseSchema()} is present, the returned options implement {@link
54+
* StructuredOutputChatOptions} carrying the serialized JSON schema.
55+
*
3456
* @param config The ADK configuration to convert
3557
* @return Spring AI ChatOptions or null if no config provided
3658
*/
37-
public ChatOptions toSpringAiChatOptions(Optional<GenerateContentConfig> config) {
59+
public @Nullable ChatOptions toSpringAiChatOptions(Optional<GenerateContentConfig> config) {
60+
Objects.requireNonNull(config, "config cannot be null");
3861
if (config.isEmpty()) {
3962
return null;
4063
}
41-
4264
GenerateContentConfig contentConfig = config.get();
43-
ChatOptions.Builder optionsBuilder = ChatOptions.builder();
4465

45-
// Map temperature (convert Float to Double)
46-
contentConfig.temperature().ifPresent(temp -> optionsBuilder.temperature(temp.doubleValue()));
66+
// responseJsonSchema may hold a genai Schema, whose JSON uses upper-case types like "STRING".
67+
Optional<Object> outputSchema =
68+
contentConfig
69+
.responseJsonSchema()
70+
.or(contentConfig::responseSchema)
71+
.map(
72+
schema ->
73+
schema instanceof Schema adkSchema
74+
? toolConverter.convertSchemaToSpringAi(adkSchema)
75+
: schema);
76+
ChatOptions.Builder<?> optionsBuilder;
77+
if (outputSchema.isPresent()) {
78+
try {
79+
String schemaJson = JsonBaseModel.getMapper().writeValueAsString(outputSchema.get());
80+
optionsBuilder = StructuredOutputChatOptions.builder().outputSchema(schemaJson);
81+
} catch (JsonProcessingException e) {
82+
throw new IllegalArgumentException("Failed to serialize response schema to JSON", e);
83+
}
84+
} else {
85+
optionsBuilder = ChatOptions.builder();
86+
}
4787

48-
// Map max output tokens
88+
contentConfig.temperature().ifPresent(temp -> optionsBuilder.temperature(temp.doubleValue()));
4989
contentConfig.maxOutputTokens().ifPresent(optionsBuilder::maxTokens);
50-
51-
// Map top P (convert Float to Double)
5290
contentConfig.topP().ifPresent(topP -> optionsBuilder.topP(topP.doubleValue()));
53-
54-
// Map top K (convert Float to Integer)
5591
contentConfig.topK().ifPresent(topK -> optionsBuilder.topK(topK.intValue()));
56-
57-
// Map stop sequences
5892
contentConfig
5993
.stopSequences()
6094
.filter(sequences -> !sequences.isEmpty())
6195
.ifPresent(optionsBuilder::stopSequences);
62-
63-
// Map presence penalty (if supported by Spring AI)
6496
contentConfig
6597
.presencePenalty()
66-
.ifPresent(
67-
penalty -> {
68-
// Spring AI may support presence penalty through model-specific options
69-
// This will be handled in provider-specific adapters
70-
});
71-
72-
// Map frequency penalty (if supported by Spring AI)
98+
.ifPresent(penalty -> optionsBuilder.presencePenalty(penalty.doubleValue()));
7399
contentConfig
74100
.frequencyPenalty()
75-
.ifPresent(
76-
penalty -> {
77-
// Spring AI may support frequency penalty through model-specific options
78-
// This will be handled in provider-specific adapters
79-
});
101+
.ifPresent(penalty -> optionsBuilder.frequencyPenalty(penalty.doubleValue()));
80102

81103
return optionsBuilder.build();
82104
}
83105

106+
/**
107+
* Converts Spring AI {@link ChatOptions} to an ADK {@link GenerateContentConfig}.
108+
*
109+
* <p>When {@code chatOptions} implements {@link StructuredOutputChatOptions} with a non-blank
110+
* output schema, the parsed schema is set as {@link GenerateContentConfig#responseJsonSchema()}
111+
* with a JSON response MIME type.
112+
*
113+
* @param chatOptions The Spring AI chat options to convert, or null
114+
* @return ADK {@link GenerateContentConfig}, or null if {@code chatOptions} is null
115+
*/
116+
public @Nullable GenerateContentConfig toGenerateContentConfig(
117+
@Nullable ChatOptions chatOptions) {
118+
if (chatOptions == null) {
119+
return null;
120+
}
121+
122+
GenerateContentConfig.Builder builder = GenerateContentConfig.builder();
123+
124+
if (chatOptions.getTemperature() != null) {
125+
builder.temperature(chatOptions.getTemperature().floatValue());
126+
}
127+
if (chatOptions.getMaxTokens() != null) {
128+
builder.maxOutputTokens(chatOptions.getMaxTokens());
129+
}
130+
if (chatOptions.getTopP() != null) {
131+
builder.topP(chatOptions.getTopP().floatValue());
132+
}
133+
if (chatOptions.getTopK() != null) {
134+
builder.topK(chatOptions.getTopK().floatValue());
135+
}
136+
if (chatOptions.getStopSequences() != null && !chatOptions.getStopSequences().isEmpty()) {
137+
builder.stopSequences(ImmutableList.copyOf(chatOptions.getStopSequences()));
138+
}
139+
if (chatOptions.getPresencePenalty() != null) {
140+
builder.presencePenalty(chatOptions.getPresencePenalty().floatValue());
141+
}
142+
if (chatOptions.getFrequencyPenalty() != null) {
143+
builder.frequencyPenalty(chatOptions.getFrequencyPenalty().floatValue());
144+
}
145+
if (chatOptions instanceof StructuredOutputChatOptions structuredOptions) {
146+
String outputSchema = structuredOptions.getOutputSchema();
147+
if (outputSchema != null && !outputSchema.isBlank()) {
148+
// Passed through as JSON schema so $defs, $ref and anyOf survive; Schema would drop them.
149+
builder
150+
.responseJsonSchema(parseOutputSchema(outputSchema))
151+
.responseMimeType(JSON_MIME_TYPE);
152+
}
153+
}
154+
155+
return builder.build();
156+
}
157+
84158
/**
85159
* Creates default ChatOptions for cases where no ADK config is provided.
86160
*
@@ -97,46 +171,144 @@ public ChatOptions createDefaultChatOptions() {
97171
* @return true if configuration is valid and supported
98172
*/
99173
public boolean isConfigurationValid(Optional<GenerateContentConfig> config) {
174+
Objects.requireNonNull(config, "config cannot be null");
100175
if (config.isEmpty()) {
101-
return true; // No config is valid
176+
return true;
102177
}
103-
104178
GenerateContentConfig contentConfig = config.get();
105179

106-
// Check for unsupported features
107180
if (contentConfig.responseSchema().isPresent()) {
108-
// Response schema might not be supported by all Spring AI models
109-
// This should be logged as a warning
110181
return false;
111182
}
112183

113184
if (contentConfig.responseMimeType().isPresent()) {
114-
// Response MIME type might not be supported by all Spring AI models
115185
return false;
116186
}
117187

118-
// Check for reasonable ranges
119-
if (contentConfig.temperature().isPresent()) {
120-
float temp = contentConfig.temperature().get();
121-
if (temp < 0.0f || temp > 2.0f) {
122-
return false; // Temperature out of reasonable range
123-
}
188+
if (contentConfig.temperature().isPresent()
189+
&& !isValidTemperature(contentConfig.temperature().get())) {
190+
return false;
191+
}
192+
193+
if (contentConfig.topP().isPresent() && !isValidTopP(contentConfig.topP().get())) {
194+
return false;
195+
}
196+
197+
if (contentConfig.topK().isPresent() && !isValidTopK(contentConfig.topK().get())) {
198+
return false;
124199
}
125200

126-
if (contentConfig.topP().isPresent()) {
127-
float topP = contentConfig.topP().get();
128-
if (topP < 0.0f || topP > 1.0f) {
129-
return false; // topP out of valid range
201+
return true;
202+
}
203+
204+
/**
205+
* Validates that Spring AI {@link ChatOptions} values fall within valid ADK generation bounds.
206+
*
207+
* @param chatOptions The Spring AI chat options to validate, or null
208+
* @return true if the options are valid
209+
*/
210+
public boolean isChatOptionsValid(@Nullable ChatOptions chatOptions) {
211+
if (chatOptions == null) {
212+
return true;
213+
}
214+
215+
if (chatOptions.getTemperature() != null && !isValidTemperature(chatOptions.getTemperature())) {
216+
return false;
217+
}
218+
219+
if (chatOptions.getTopP() != null && !isValidTopP(chatOptions.getTopP())) {
220+
return false;
221+
}
222+
223+
if (chatOptions.getTopK() != null && !isValidTopK(chatOptions.getTopK())) {
224+
return false;
225+
}
226+
227+
return true;
228+
}
229+
230+
private static Map<String, Object> parseOutputSchema(String outputSchema) {
231+
try {
232+
Map<String, Object> schema =
233+
JsonBaseModel.getMapper()
234+
.readerFor(MAP_TYPE_REFERENCE)
235+
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
236+
.readValue(outputSchema);
237+
if (schema == null) {
238+
throw new IllegalArgumentException("Spring AI output schema must be a JSON object");
130239
}
240+
return schema;
241+
} catch (JsonProcessingException e) {
242+
throw new IllegalArgumentException("Failed to parse Spring AI output schema", e);
243+
}
244+
}
245+
246+
/**
247+
* Rewrites a JSON output schema for OpenAI strict structured outputs, as Python ADK's LiteLLM
248+
* model does: every object schema gets {@code additionalProperties: false} and lists all its
249+
* properties as required, and {@code $ref} nodes lose their sibling keywords.
250+
*/
251+
static String toOpenAiStrictSchema(String outputSchema) {
252+
Map<String, Object> schema = parseOutputSchema(outputSchema);
253+
enforceOpenAiStrictSchema(schema);
254+
try {
255+
return JsonBaseModel.getMapper().writeValueAsString(schema);
256+
} catch (JsonProcessingException e) {
257+
throw new IllegalArgumentException("Failed to serialize response schema to JSON", e);
131258
}
259+
}
132260

133-
if (contentConfig.topK().isPresent()) {
134-
float topK = contentConfig.topK().get();
135-
if (topK < 1 || topK > 64) {
136-
return false; // topK out of valid range
261+
private static void enforceOpenAiStrictSchema(Map<String, Object> schema) {
262+
if (schema.containsKey("$ref")) {
263+
schema.keySet().retainAll(Set.of("$ref"));
264+
return;
265+
}
266+
// A genai Schema serializes types in upper case ("OBJECT"); JSON Schema and OpenAI expect
267+
// lower case, so lowercase them first, as Python ADK's lowercase_schema_types does.
268+
if (schema.get("type") instanceof String typeName) {
269+
schema.put("type", typeName.toLowerCase(Locale.ROOT));
270+
} else if (schema.get("type") instanceof List<?> typeNames) {
271+
schema.put(
272+
"type",
273+
typeNames.stream()
274+
.map(t -> t instanceof String name ? name.toLowerCase(Locale.ROOT) : t)
275+
.toList());
276+
}
277+
if ("object".equals(schema.get("type"))
278+
&& schema.get("properties") instanceof Map<?, ?> properties) {
279+
schema.put("additionalProperties", false);
280+
schema.put(
281+
"required",
282+
properties.keySet().stream().map(String::valueOf).sorted().collect(toImmutableList()));
283+
}
284+
List<Object> subschemas = new ArrayList<>();
285+
for (String mapKey : List.of("$defs", "definitions", "properties")) {
286+
if (schema.get(mapKey) instanceof Map<?, ?> map) {
287+
subschemas.addAll(map.values());
137288
}
138289
}
290+
for (String listKey : List.of("anyOf", "oneOf", "allOf")) {
291+
if (schema.get(listKey) instanceof List<?> list) {
292+
subschemas.addAll(list);
293+
}
294+
}
295+
subschemas.add(schema.get("items"));
296+
for (Object subschema : subschemas) {
297+
if (subschema instanceof Map<?, ?> map) {
298+
enforceOpenAiStrictSchema(ToolConverter.castToMap(map));
299+
}
300+
}
301+
}
139302

140-
return true;
303+
private static boolean isValidTemperature(double temp) {
304+
return temp >= 0.0 && temp <= 2.0;
305+
}
306+
307+
private static boolean isValidTopP(double topP) {
308+
return topP >= 0.0 && topP <= 1.0;
309+
}
310+
311+
private static boolean isValidTopK(double topK) {
312+
return topK >= 1.0 && topK <= 64.0;
141313
}
142314
}

0 commit comments

Comments
 (0)