diff --git a/core/src/main/java/com/google/adk/models/chat/ChatCompletionsRequest.java b/core/src/main/java/com/google/adk/models/chat/ChatCompletionsRequest.java index b73ac4fdf..e8eb159d8 100644 --- a/core/src/main/java/com/google/adk/models/chat/ChatCompletionsRequest.java +++ b/core/src/main/java/com/google/adk/models/chat/ChatCompletionsRequest.java @@ -29,8 +29,10 @@ import com.fasterxml.jackson.databind.module.SimpleModule; import com.google.adk.JsonBaseModel; import com.google.adk.models.LlmRequest; +import com.google.common.base.Ascii; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; import com.google.genai.types.Content; import com.google.genai.types.FunctionDeclaration; import com.google.genai.types.FunctionResponse; @@ -40,10 +42,13 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Base64; +import java.util.Collection; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -572,7 +577,7 @@ private static void handleConfigOptions( schema.schema = objectMapper.convertValue( config.responseJsonSchema().get(), new TypeReference>() {}); - schema.strict = true; + applyStrictOutputRules(schema); format.jsonSchema = schema; request.responseFormat = format; } else if (config.responseSchema().isPresent()) { @@ -582,7 +587,7 @@ private static void handleConfigOptions( schema.schema = objectMapper.convertValue( config.responseSchema().get(), new TypeReference>() {}); - schema.strict = true; + applyStrictOutputRules(schema); format.jsonSchema = schema; request.responseFormat = format; } else if (config.responseMimeType().isPresent() @@ -596,6 +601,113 @@ private static void handleConfigOptions( } } + /** + * Prepares a response schema for strict structured outputs, closing its object nodes and clearing + * the strict flag for the violations that can be detected locally. + * + * @param schema The json_schema payload to adjust in place. + */ + private static void applyStrictOutputRules(ResponseFormatJsonSchema.JsonSchema schema) { + closeObjectSchemas(schema.schema); + schema.strict = isStrictCompatible(schema.schema); + if (!schema.strict) { + logger.warn( + "Response schema is not strict-compatible; sending response_format with strict=false."); + } + } + + /** Schema keywords whose value is a map of name to subschema. */ + private static final ImmutableList SCHEMA_MAP_KEYWORDS = + ImmutableList.of("$defs", "properties"); + + /** Schema keywords whose value is a subschema, or a list of them. */ + private static final ImmutableList SCHEMA_LIST_KEYWORDS = + ImmutableList.of("anyOf", "oneOf", "allOf", "items"); + + /** + * Returns the subschemas nested directly under {@code node}. + * + * @param node The schema node to walk. + */ + private static ImmutableList subschemas(Map node) { + ImmutableList.Builder nested = ImmutableList.builder(); + for (String container : SCHEMA_MAP_KEYWORDS) { + if (node.get(container) instanceof Map members) { + members.values().stream().filter(Objects::nonNull).forEach(nested::add); + } + } + for (String keyword : SCHEMA_LIST_KEYWORDS) { + Object value = node.get(keyword); + // "items" is a single schema in draft 2020-12 but a list in the older tuple form. + if (value instanceof List members) { + members.stream().filter(Objects::nonNull).forEach(nested::add); + } else if (value != null) { + nested.add(value); + } + } + return nested.build(); + } + + /** + * Adds {@code additionalProperties: false} to every object node that declares properties, which + * strict structured outputs require and the genai {@code Schema} type cannot express. + * + * @param node The schema node to adjust in place. + */ + private static void closeObjectSchemas(Object node) { + if (!(node instanceof Map rawNode)) { + return; + } + // Safe: the tree comes from convertValue into Map, so every key is a String. + @SuppressWarnings("unchecked") + Map schema = (Map) rawNode; + if (declaresProperties(schema) && schema.get("additionalProperties") == null) { + schema.put("additionalProperties", false); + } + subschemas(schema).forEach(ChatCompletionsRequest::closeObjectSchemas); + } + + /** + * Returns whether every object node stays closed and declares exactly the properties it lists in + * {@code required}, which is the part of strict mode checkable without knowing the endpoint. + * + * @param node The schema node to inspect. + */ + private static boolean isStrictCompatible(Object node) { + if (!(node instanceof Map rawNode)) { + return true; + } + Map schema = rawNode; + Object additionalProperties = schema.get("additionalProperties"); + if (additionalProperties != null && !additionalProperties.equals(false)) { + return false; + } + if (declaresProperties(schema)) { + Object required = schema.get("required"); + Set listed = required instanceof List names ? new HashSet<>(names) : ImmutableSet.of(); + if (!listed.equals(((Map) schema.get("properties")).keySet())) { + return false; + } + } + return subschemas(schema).stream().allMatch(ChatCompletionsRequest::isStrictCompatible); + } + + /** Returns whether {@code schema} is an object node carrying a properties map. */ + private static boolean declaresProperties(Map schema) { + return isObjectType(schema.get("type")) && schema.get("properties") instanceof Map; + } + + /** Returns whether {@code type} names an object, allowing the {@code ["object","null"]} form. */ + private static boolean isObjectType(Object type) { + if (type instanceof String name) { + return Ascii.equalsIgnoreCase(name, "object"); + } + return type instanceof Collection names + && names.stream() + .anyMatch( + name -> name instanceof String text && Ascii.equalsIgnoreCase(text, "object")); + } + /** * Updates the request tools list based on the provided tools configuration. * diff --git a/core/src/test/java/com/google/adk/models/chat/ChatCompletionsRequestTest.java b/core/src/test/java/com/google/adk/models/chat/ChatCompletionsRequestTest.java index fdb72c863..f09880b9e 100644 --- a/core/src/test/java/com/google/adk/models/chat/ChatCompletionsRequestTest.java +++ b/core/src/test/java/com/google/adk/models/chat/ChatCompletionsRequestTest.java @@ -19,6 +19,7 @@ import static com.google.common.truth.Truth.assertThat; import static java.nio.charset.StandardCharsets.UTF_8; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.adk.JsonBaseModel; import com.google.adk.models.LlmRequest; @@ -38,7 +39,9 @@ import com.google.genai.types.Tool; import com.google.genai.types.ToolConfig; import java.util.AbstractMap; +import java.util.Arrays; import java.util.Base64; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -825,6 +828,357 @@ public void testFromLlmRequest_withRawResponseJsonSchemaPrecedenceOverTypedSchem assertThat(format.jsonSchema.schema).isEqualTo(rawSchema); } + // ----- strict structured-output normalization --------------------------------------------- + // These assert the serialized payload, which is what the HTTP client puts on the wire. + + private static JsonNode serializedSchema(ChatCompletionsRequest request) throws Exception { + ChatCompletionsRequest.ResponseFormatJsonSchema format = + (ChatCompletionsRequest.ResponseFormatJsonSchema) request.responseFormat; + return JsonBaseModel.getMapper() + .readTree(JsonBaseModel.getMapper().writeValueAsString(format)) + .at("/json_schema/schema"); + } + + private static boolean serializedStrict(ChatCompletionsRequest request) throws Exception { + ChatCompletionsRequest.ResponseFormatJsonSchema format = + (ChatCompletionsRequest.ResponseFormatJsonSchema) request.responseFormat; + return JsonBaseModel.getMapper() + .readTree(JsonBaseModel.getMapper().writeValueAsString(format)) + .at("/json_schema/strict") + .asBoolean(); + } + + private static ChatCompletionsRequest requestWithRawSchema(Map rawSchema) { + return ChatCompletionsRequest.fromLlmRequest( + LlmRequest.builder() + .model("openai-compatible-model") + .config(GenerateContentConfig.builder().responseJsonSchema(rawSchema).build()) + .contents(ImmutableList.of()) + .build(), + false); + } + + private static ChatCompletionsRequest requestWithTypedSchema(Schema outputSchema) { + return ChatCompletionsRequest.fromLlmRequest( + LlmRequest.builder() + .model("openai-compatible-model") + .outputSchema(outputSchema) + .contents(ImmutableList.of()) + .build(), + false); + } + + @Test + public void testFromLlmRequest_typedSchema_closesRootNestedAndArrayItemObjects() + throws Exception { + Schema outputSchema = + Schema.builder() + .type("OBJECT") + .properties( + ImmutableMap.of( + "addr", + Schema.builder() + .type("OBJECT") + .properties( + ImmutableMap.of("city", Schema.builder().type("STRING").build())) + .required(ImmutableList.of("city")) + .build(), + "tags", + Schema.builder() + .type("ARRAY") + .items( + Schema.builder() + .type("OBJECT") + .properties( + ImmutableMap.of("k", Schema.builder().type("STRING").build())) + .required(ImmutableList.of("k")) + .build()) + .build())) + .required(ImmutableList.of("addr", "tags")) + .build(); + + ChatCompletionsRequest request = requestWithTypedSchema(outputSchema); + JsonNode schema = serializedSchema(request); + + assertThat(schema.path("additionalProperties").toString()).isEqualTo("false"); + assertThat(schema.at("/properties/addr/additionalProperties").toString()).isEqualTo("false"); + assertThat(schema.at("/properties/tags/items/additionalProperties").toString()) + .isEqualTo("false"); + assertThat(serializedStrict(request)).isTrue(); + } + + @Test + public void testFromLlmRequest_optionalProperty_downgradesToNonStrictAndStillCloses() + throws Exception { + Schema outputSchema = + Schema.builder() + .type("OBJECT") + .properties( + ImmutableMap.of( + "rootCause", Schema.builder().type("STRING").build(), + "notes", Schema.builder().type("STRING").build())) + .required(ImmutableList.of("rootCause")) + .build(); + + ChatCompletionsRequest request = requestWithTypedSchema(outputSchema); + + assertThat(serializedStrict(request)).isFalse(); + assertThat(serializedSchema(request).path("additionalProperties").toString()) + .isEqualTo("false"); + } + + @Test + public void testFromLlmRequest_nullableObjectTypeArray_isTreatedAsObject() throws Exception { + ImmutableMap rawSchema = + ImmutableMap.of( + "type", + "object", + "properties", + ImmutableMap.of( + "addr", + ImmutableMap.of( + "type", + ImmutableList.of("object", "null"), + "properties", + ImmutableMap.of("city", ImmutableMap.of("type", "string")), + "required", + ImmutableList.of("city"))), + "required", + ImmutableList.of("addr")); + + JsonNode schema = serializedSchema(requestWithRawSchema(rawSchema)); + + assertThat(schema.at("/properties/addr/additionalProperties").toString()).isEqualTo("false"); + } + + @Test + public void testFromLlmRequest_requiredNamingUnknownProperty_downgradesToNonStrict() + throws Exception { + ImmutableMap rawSchema = + ImmutableMap.of( + "type", "object", + "properties", ImmutableMap.of("a", ImmutableMap.of("type", "string")), + "required", ImmutableList.of("a", "ghost")); + + assertThat(serializedStrict(requestWithRawSchema(rawSchema))).isFalse(); + } + + @Test + public void testFromLlmRequest_callerAdditionalProperties_isKeptAndSiblingStillCloses() + throws Exception { + ImmutableMap rawSchema = + ImmutableMap.of( + "type", + "object", + "properties", + ImmutableMap.of( + "open", + ImmutableMap.of( + "type", + "object", + "properties", + ImmutableMap.of("a", ImmutableMap.of("type", "string")), + "required", + ImmutableList.of("a"), + "additionalProperties", + true), + "closed", + ImmutableMap.of( + "type", "object", + "properties", ImmutableMap.of("b", ImmutableMap.of("type", "string")), + "required", ImmutableList.of("b"))), + "required", + ImmutableList.of("open", "closed")); + + ChatCompletionsRequest request = requestWithRawSchema(rawSchema); + JsonNode schema = serializedSchema(request); + + assertThat(schema.at("/properties/open/additionalProperties").toString()).isEqualTo("true"); + assertThat(schema.at("/properties/closed/additionalProperties").toString()).isEqualTo("false"); + assertThat(serializedStrict(request)).isFalse(); + } + + @Test + public void testFromLlmRequest_emptyPropertiesMap_isClosed() throws Exception { + ImmutableMap rawSchema = + ImmutableMap.of("type", "object", "properties", ImmutableMap.of()); + + ChatCompletionsRequest request = requestWithRawSchema(rawSchema); + + assertThat(serializedSchema(request).path("additionalProperties").toString()) + .isEqualTo("false"); + assertThat(serializedStrict(request)).isTrue(); + } + + @Test + public void testFromLlmRequest_objectWithoutProperties_isLeftUnchanged() throws Exception { + ImmutableMap rawSchema = ImmutableMap.of("type", "object"); + + ChatCompletionsRequest request = requestWithRawSchema(rawSchema); + + assertThat(serializedSchema(request).has("additionalProperties")).isFalse(); + assertThat(serializedStrict(request)).isTrue(); + } + + @Test + public void testFromLlmRequest_combinatorMembers_areClosed() throws Exception { + ImmutableMap member = + ImmutableMap.of( + "type", "object", + "properties", ImmutableMap.of("a", ImmutableMap.of("type", "string")), + "required", ImmutableList.of("a")); + ImmutableMap rawSchema = + ImmutableMap.of( + "type", + "object", + "properties", + ImmutableMap.of( + "either", ImmutableMap.of("anyOf", ImmutableList.of(member)), + "exactly", ImmutableMap.of("oneOf", ImmutableList.of(member))), + "required", + ImmutableList.of("either", "exactly")); + + JsonNode schema = serializedSchema(requestWithRawSchema(rawSchema)); + + assertThat(schema.at("/properties/either/anyOf/0/additionalProperties").toString()) + .isEqualTo("false"); + assertThat(schema.at("/properties/exactly/oneOf/0/additionalProperties").toString()) + .isEqualTo("false"); + } + + @Test + public void testFromLlmRequest_rawSchema_doesNotMutateCallerMap() throws Exception { + Map inner = new LinkedHashMap<>(); + inner.put("type", "string"); + Map properties = new LinkedHashMap<>(); + properties.put("a", inner); + Map rawSchema = new LinkedHashMap<>(); + rawSchema.put("type", "object"); + rawSchema.put("properties", properties); + rawSchema.put("required", ImmutableList.of("a")); + String before = rawSchema.toString(); + + ChatCompletionsRequest unusedRequest = requestWithRawSchema(rawSchema); + + assertThat(rawSchema.toString()).isEqualTo(before); + } + + @Test + public void testFromLlmRequest_nullSubschemaValues_areTolerated() throws Exception { + Map properties = new LinkedHashMap<>(); + properties.put("a", null); + properties.put( + "b", ImmutableMap.of("anyOf", Arrays.asList(null, ImmutableMap.of("type", "string")))); + Map rawSchema = new LinkedHashMap<>(); + rawSchema.put("type", "object"); + rawSchema.put("properties", properties); + + JsonNode schema = serializedSchema(requestWithRawSchema(rawSchema)); + + assertThat(schema.path("additionalProperties").toString()).isEqualTo("false"); + } + + @Test + public void testFromLlmRequest_tupleFormItems_areClosed() throws Exception { + ImmutableMap member = + ImmutableMap.of( + "type", "object", + "properties", ImmutableMap.of("a", ImmutableMap.of("type", "string")), + "required", ImmutableList.of("a")); + ImmutableMap rawSchema = + ImmutableMap.of( + "type", + "object", + "properties", + ImmutableMap.of( + "pair", ImmutableMap.of("type", "array", "items", ImmutableList.of(member))), + "required", + ImmutableList.of("pair")); + + JsonNode schema = serializedSchema(requestWithRawSchema(rawSchema)); + + assertThat(schema.at("/properties/pair/items/0/additionalProperties").toString()) + .isEqualTo("false"); + } + + @Test + public void testFromLlmRequest_nullInsideRequired_isTolerated() throws Exception { + Map rawSchema = new LinkedHashMap<>(); + rawSchema.put("type", "object"); + rawSchema.put("properties", ImmutableMap.of("a", ImmutableMap.of("type", "string"))); + rawSchema.put("required", Arrays.asList("a", null)); + + ChatCompletionsRequest request = requestWithRawSchema(rawSchema); + + assertThat(serializedStrict(request)).isFalse(); + assertThat(serializedSchema(request).path("additionalProperties").toString()) + .isEqualTo("false"); + } + + @Test + public void testFromLlmRequest_defsAreNormalized() throws Exception { + ImmutableMap rawSchema = + ImmutableMap.of( + "type", + "object", + "$defs", + ImmutableMap.of( + "Addr", + ImmutableMap.of( + "type", "object", + "properties", ImmutableMap.of("city", ImmutableMap.of("type", "string")), + "required", ImmutableList.of("city"))), + "properties", + ImmutableMap.of("addr", ImmutableMap.of("$ref", "#/$defs/Addr")), + "required", + ImmutableList.of("addr")); + + JsonNode schema = serializedSchema(requestWithRawSchema(rawSchema)); + + assertThat(schema.at("/$defs/Addr/additionalProperties").toString()).isEqualTo("false"); + } + + @Test + public void testFromLlmRequest_uppercaseObjectType_isTreatedAsObject() throws Exception { + ImmutableMap rawSchema = + ImmutableMap.of( + "type", "OBJECT", + "properties", ImmutableMap.of("a", ImmutableMap.of("type", "STRING")), + "required", ImmutableList.of("a")); + + JsonNode schema = serializedSchema(requestWithRawSchema(rawSchema)); + + assertThat(schema.path("additionalProperties").toString()).isEqualTo("false"); + } + + @Test + public void testFromLlmRequest_explicitNullAdditionalProperties_isClosed() throws Exception { + Map rawSchema = new LinkedHashMap<>(); + rawSchema.put("type", "object"); + rawSchema.put("properties", ImmutableMap.of("a", ImmutableMap.of("type", "string"))); + rawSchema.put("required", ImmutableList.of("a")); + rawSchema.put("additionalProperties", null); + + ChatCompletionsRequest request = requestWithRawSchema(rawSchema); + + assertThat(serializedSchema(request).path("additionalProperties").toString()) + .isEqualTo("false"); + assertThat(serializedStrict(request)).isTrue(); + } + + @Test + public void testFromLlmRequest_propertiesWithoutObjectType_isLeftUnchanged() throws Exception { + ImmutableMap rawSchema = + ImmutableMap.of( + "properties", ImmutableMap.of("a", ImmutableMap.of("type", "string")), + "required", ImmutableList.of("a")); + + ChatCompletionsRequest request = requestWithRawSchema(rawSchema); + + assertThat(serializedSchema(request).has("additionalProperties")).isFalse(); + assertThat(serializedStrict(request)).isTrue(); + } + // ----- thought_signature round-trip on the request side ---------------------------------- // // The four chat source files share a single contract for round-tripping Gemini's