1515 */
1616package 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 ;
1825import 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 ;
1932import java .util .Optional ;
33+ import java .util .Set ;
34+ import org .jspecify .annotations .Nullable ;
2035import 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 */
2942public 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