diff --git a/integrations/chat-models/gemini/pom.xml b/integrations/chat-models/gemini/pom.xml index 7ceb4fcc7..45516d3f4 100644 --- a/integrations/chat-models/gemini/pom.xml +++ b/integrations/chat-models/gemini/pom.xml @@ -43,6 +43,17 @@ under the License. google-genai ${google.genai.version} + + + + com.github.victools + jsonschema-generator + + + + com.github.victools + jsonschema-module-jackson + diff --git a/integrations/chat-models/gemini/src/main/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnection.java b/integrations/chat-models/gemini/src/main/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnection.java index 1c94602c1..b4f128aba 100644 --- a/integrations/chat-models/gemini/src/main/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnection.java +++ b/integrations/chat-models/gemini/src/main/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnection.java @@ -19,7 +19,17 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.victools.jsonschema.generator.Option; +import com.github.victools.jsonschema.generator.OptionPreset; +import com.github.victools.jsonschema.generator.SchemaGenerator; +import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder; +import com.github.victools.jsonschema.generator.SchemaVersion; +import com.github.victools.jsonschema.generator.impl.PropertySortUtils; +import com.github.victools.jsonschema.module.jackson.JacksonModule; +import com.github.victools.jsonschema.module.jackson.JacksonOption; import com.google.genai.Client; import com.google.genai.types.Candidate; import com.google.genai.types.Content; @@ -48,6 +58,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; @@ -102,6 +113,28 @@ public class GeminiChatModelConnection extends BaseChatModelConnection { private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + // Models for which Google documents native structured output. + // Source of truth: each model's Capabilities row at + // https://ai.google.dev/gemini-api/docs/models + // + // Google added JSON Schema support to every actively supported Gemini model, and every model + // predating that rollout has since been shut down, so the whole live text family is matched by + // the family prefix rather than enumerated. A new Flash generation has shipped roughly monthly, + // so an enumerated list would report not-capable for models that do work. + // + // Capability splits by output modality, not by generation: the image, speech, audio, Live, + // transcription, video and embedding variants share the family prefix but expose no schema + // parameter, so a modality marker is checked first and wins over the prefix. The markers also + // cover gemini-2.5-flash-image, whose published capability row claims support that the service + // rejects. + // + // A name outside the family — a Gemma model served by the same endpoint, a tuned model, or a + // path-qualified form such as models/gemini-2.5-flash — reports not-capable and degrades to the + // prompt fallback rather than failing at the provider. + private static final String NATIVE_STRUCTURED_OUTPUT_FAMILY_PREFIX = "gemini-"; + private static final Set NON_TEXT_MODALITY_MARKERS = + Set.of("-image", "-tts", "-audio", "-live", "-transcribe", "-embedding", "-omni"); + private final ObjectMapper mapper = new ObjectMapper(); private final Client client; private final String defaultModel; @@ -173,11 +206,67 @@ public void close() { this.client.close(); } + /** + * Whether Google documents native structured output for {@code effectiveModel}. + * + *

A {@code true} means the request carries the schema as a native {@code responseJsonSchema} + * and the model is one Google documents as accepting one. What that buys is structural + * conformance: the response is syntactically valid JSON whose object shape, key set and value + * types follow the schema as the service interpreted it. + * + *

It does not buy conformance to every constraint the schema expresses. Gemini supports a + * subset of JSON Schema and ignores the keywords outside that subset, server-side, without + * reporting which. A {@code pattern}, {@code minLength}, {@code maxLength}, {@code + * minProperties} or {@code maxProperties} in a derived schema is accepted by the request and + * has no effect on the response, so well-formed JSON of the right shape is not evidence that + * every declared constraint held. It does not buy semantically correct values either. + * + *

Capability is read from the model name alone, so a Gemini model released after this was + * written is treated as capable. One that turns out not to support structured output fails at + * the provider with {@code 400 INVALID_ARGUMENT}, "JSON mode is not enabled for this model", + * rather than degrading quietly. A name outside the family — a Gemma model on the same + * endpoint, a tuned model, or the path-qualified {@code models/gemini-2.5-flash} form the SDK + * also accepts — reports not-capable and keeps the prompt-engineering fallback. + */ + @Override + protected boolean supportsNativeStructuredOutput(String effectiveModel) { + if (effectiveModel == null || effectiveModel.isBlank()) { + return false; + } + if (NON_TEXT_MODALITY_MARKERS.stream().anyMatch(effectiveModel::contains)) { + return false; + } + return effectiveModel.startsWith(NATIVE_STRUCTURED_OUTPUT_FAMILY_PREFIX) + && effectiveModel.length() > NATIVE_STRUCTURED_OUTPUT_FAMILY_PREFIX.length(); + } + @Override public ChatMessage chat( List messages, List tools, Map arguments) { + return chat(messages, tools, arguments, null); + } + + /** + * Translates {@code outputSchema} into Gemini's native {@code responseJsonSchema} when it is a + * POJO {@link Class}, the request carries no tools, and the effective model is one Google + * documents structured-output support for. Any other combination sends no derived schema, so a + * schema that cannot be sent natively degrades to the prompt-engineering fallback rather than + * failing at the provider. + * + *

The tools condition is a provider constraint rather than a preference: outside a + * documented preview, Gemini answers a request that combines function declarations with a JSON + * response mime type with {@code 400 INVALID_ARGUMENT}, "Function calling with a response mime + * type: 'application/json' is unsupported". The request therefore proceeds with its tools and + * without the schema. + */ + @Override + public ChatMessage chat( + List messages, + List tools, + Map arguments, + Object outputSchema) { Map args = arguments != null ? new HashMap<>(arguments) : new HashMap<>(); Object modelObj = args.remove("model"); @@ -202,7 +291,8 @@ public ChatMessage chat( .map(m -> convertToContent(m, toolCallIdToName)) .collect(Collectors.toList()); - GenerateContentConfig config = buildConfig(messages, tools, args); + GenerateContentConfig config = + buildConfig(messages, tools, args, modelName, outputSchema); GenerateContentResponse response = client.models.generateContent(modelName, contents, config); @@ -251,11 +341,15 @@ static Map buildToolCallIdToNameMap(List messages) return map; } - // Package-visible for unit testing of the request-config assembly. + // Package-visible for unit testing of the request-config assembly. modelName is passed + // explicitly rather than read from arguments: chat() removes the model key before calling + // this method, so the map never carries it here. GenerateContentConfig buildConfig( List messages, List tools, - Map arguments) { + Map arguments, + String modelName, + Object outputSchema) { GenerateContentConfig.Builder builder = GenerateContentConfig.builder(); Content systemInstruction = extractSystemInstruction(messages); @@ -284,9 +378,120 @@ GenerateContentConfig buildConfig( builder.tools(List.of(convertTools(tools))); } + // Native structured output applies only for a POJO Class schema; any other schema form, + // such as a RowTypeInfo wrapped in OutputSchema, keeps the prompt-engineering fallback. + // Nothing above writes either field this branch sets: the keys read directly are + // temperature and max_output_tokens, and applyAdditionalKwargs recognizes only top_k, + // top_p and stop_sequences, so there is no caller-supplied value to collide with. + // + // TODO(#912): the requested strategy is not visible here, so this re-check cannot tell an + // explicit NATIVE request apart from one that merely resolved to native. A caller asking + // for NATIVE on a schema form, a model, or a tool-carrying request this branch skips + // therefore gets an unconstrained response instead of an error. Once strategy resolution is + // wired up, NATIVE must either bypass this capability re-check or fail explicitly. + if (outputSchema instanceof Class + && (tools == null || tools.isEmpty()) + && supportsNativeStructuredOutput(modelName)) { + builder.responseMimeType("application/json"); + builder.responseJsonSchema(toNativeJsonSchema((Class) outputSchema)); + } + return builder.build(); } + // Derives the JSON Schema Gemini's responseJsonSchema field expects from a POJO class. Gemini + // supports a subset of JSON Schema and ignores the keywords outside that subset server-side + // without reporting which, so every setting below is chosen against that published subset: + // + // - DRAFT_2020_12 is the draft whose keywords Gemini's supported list names: $defs and + // prefixItems are listed, while the older drafts' definitions and tuple-form items are not. + // - The PLAIN_JSON preset keeps generation to fields. A preset is mandatory, and the + // generator's default one, FULL_DOCUMENTATION, surfaces getters as properties of their + // own, named after the accessor call, e.g. "getSummary()". + // - MAP_VALUES_AS_ADDITIONAL_PROPERTIES gives a Map its value schema, as an + // additionalProperties keyword carrying the declared value type. Dropped from this recipe, + // the map instead takes the additionalProperties:false of the option below and admits no + // entries at all. + // - FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT closes every object. No Gemini document states + // that a schema omitting the keyword is closed, and under ordinary JSON Schema semantics it + // is not, so without it a response may carry an undeclared key that the ObjectMapper + // read-back then rejects. If the service honors the keyword the object is closed; if it + // does not, the keyword is ignored like any other unsupported one, which is where omitting + // it would have left us. It applies to the enclosing object and leaves a Map's declared + // value schema alone. + // - Sorting fields before methods and applying no further comparison leaves properties in + // declaration order, which keeps the emitted document stable rather than alphabetized. It + // is not an ordering guarantee: Gemini's ordering knob is the non-standard propertyOrdering + // keyword, which this generator never emits. + // - The required check marks every field required except an Optional one. Gemini treats a + // field the schema does not list as required as optional and lets the model skip it, while + // marking everything required would force the fields a caller declared omissible. + // - The Jackson module makes the schema name properties the way Jackson names them. The + // response is read back into the same class with an ObjectMapper, so a property that + // @JsonProperty renames or @JsonIgnore drops has to be stated in the schema under the name + // the mapper reads, or a response that satisfies the schema still fails to deserialize. + // Enum constants carry the same hazard: the FLATTENED_ENUMS options list each constant by + // its @JsonValue method or @JsonProperty value, as the mapper reads it. An enum annotating + // only some constants falls back to Java names for all of them, so its annotated constants + // do not read back. No other JacksonOption is enabled, so the required set and property + // order stay as configured above. + // + // DEFINITION_FOR_MAIN_SCHEMA is deliberately absent. Without it a recursive type emits + // {"$ref": "#"} at the recursion point, which is the form Google's own recursion example uses. + // Enabling it instead produces a $defs entry referencing another $defs entry, a shape no + // published Gemini example demonstrates. + private static ObjectNode toNativeJsonSchema(Class schemaClass) { + SchemaGeneratorConfigBuilder configBuilder = + new SchemaGeneratorConfigBuilder( + SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON) + .with(Option.MAP_VALUES_AS_ADDITIONAL_PROPERTIES) + .with(Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT) + .with( + new JacksonModule( + JacksonOption.FLATTENED_ENUMS_FROM_JSONVALUE, + JacksonOption.FLATTENED_ENUMS_FROM_JSONPROPERTY)); + configBuilder + .forTypesInGeneral() + .withPropertySorter(PropertySortUtils.SORT_PROPERTIES_FIELDS_BEFORE_METHODS); + configBuilder + .forFields() + .withRequiredCheck(field -> !Optional.class.equals(field.getRawMember().getType())); + ObjectNode schema = new SchemaGenerator(configBuilder.build()).generateSchema(schemaClass); + stripRefSiblings(schema); + return schema; + } + + // Gemini states that a sub-schema setting $ref may set no other property except those starting + // with a $, so every non-$ sibling is removed wherever a $ref appears. The generator writes a + // referencing field's own keywords beside the reference, and two shapes are measured to produce + // the pairing: a described field whose type is used more than once, which is extracted into + // $defs and referenced there, and a described field that recurses into its own type, which + // references the document root. What the strip removes is not only documentation: a Map's value + // schema is written beside a $ref the same way a description is. + // + // The test is on the $ref value rather than on the presence of a member named $ref, because a + // POJO may declare a property called $ref: its schema is then an object sitting under the + // enclosing properties map, which makes that map itself carry a $ref member while being an + // ordinary properties map rather than a reference. A reference's own $ref value is a URI + // string, so requiring a textual value tells the two apart. Matching on presence alone would + // delete every other declared property from such a map while required still listed them, + // producing a document nothing can satisfy. + private static void stripRefSiblings(JsonNode node) { + if (node instanceof ObjectNode && node.path("$ref").isTextual()) { + ObjectNode object = (ObjectNode) node; + List siblings = new ArrayList<>(); + object.fieldNames() + .forEachRemaining( + name -> { + if (!name.startsWith("$")) { + siblings.add(name); + } + }); + siblings.forEach(object::remove); + } + node.forEach(GeminiChatModelConnection::stripRefSiblings); + } + // Package-visible for unit testing of the additional-kwargs forwarding. void applyAdditionalKwargs(GenerateContentConfig.Builder builder, Map kwargs) { for (Map.Entry entry : kwargs.entrySet()) { diff --git a/integrations/chat-models/gemini/src/test/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnectionTest.java b/integrations/chat-models/gemini/src/test/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnectionTest.java index db87d6c82..222db434c 100644 --- a/integrations/chat-models/gemini/src/test/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnectionTest.java +++ b/integrations/chat-models/gemini/src/test/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnectionTest.java @@ -18,6 +18,14 @@ package org.apache.flink.agents.integrations.chatmodels.gemini; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonValue; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.genai.types.Content; import com.google.genai.types.FunctionCall; import com.google.genai.types.FunctionDeclaration; @@ -35,12 +43,17 @@ import org.apache.flink.agents.api.tools.ToolType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.ArrayList; import java.util.Base64; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -53,6 +66,9 @@ class GeminiChatModelConnectionTest { private static final ResourceContext NOOP = ResourceContext.fromGetResource((a, b) -> null); + /** A model Google documents native structured-output support for. */ + private static final String CAPABLE_MODEL = "gemini-2.5-flash"; + private static ResourceDescriptor descriptor(String apiKey, String baseUrl, String model) { ResourceDescriptor.Builder b = ResourceDescriptor.Builder.newBuilder(GeminiChatModelConnection.class.getName()); @@ -81,6 +97,154 @@ private static Map params() { return new HashMap<>(); } + private static List userMessage() { + return List.of(ChatMessage.user("hi")); + } + + private static JsonNode nativeSchema(GenerateContentConfig config) { + return (JsonNode) config.responseJsonSchema().orElseThrow(); + } + + private static List fieldNames(JsonNode node) { + List names = new ArrayList<>(); + node.fieldNames().forEachRemaining(names::add); + return names; + } + + /** + * Output schema fixture shaped to expose the derivation settings: fields are declared out of + * alphabetical order, {@code counts} is a map whose values carry a type, and {@code note} is + * the only optional field. + */ + public static class Report { + public String summary; + public Map counts; + public Optional note; + public int total; + } + + /** + * Output schema fixture shaped to expose Jackson's property model. + * + *

{@code name} is deserialized from {@code full_name} rather than from the Java field name, + * and {@code secret} is not deserialized at all. + */ + public static class Profile { + @JsonProperty("full_name") + public String name; + + @JsonIgnore public String secret; + + public int age; + } + + /** Nested type reused by two described fields of {@link Addresses}. */ + public static class Address { + public String street; + } + + /** + * Output schema fixture whose reused nested type is extracted into {@code $defs}, so each + * described field emits a {@code $ref} that would otherwise carry a {@code description} + * sibling. + */ + public static class Addresses { + @JsonPropertyDescription("home address") + public Address home; + + @JsonPropertyDescription("work address") + public Address work; + } + + /** + * Output schema fixture that reuses {@link Addresses}, so the forbidden pairing also appears + * inside a {@code $defs} entry rather than only among the root's own properties. + */ + public static class Building { + @JsonPropertyDescription("primary occupant") + public Addresses primary; + + @JsonPropertyDescription("secondary occupant") + public Addresses secondary; + } + + /** + * Subtype union whose branches victools renders as a {@code $ref} each, inside an {@code anyOf} + * array. + */ + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind") + @JsonSubTypes({ + @JsonSubTypes.Type(value = Dog.class, name = "dog"), + @JsonSubTypes.Type(value = Cat.class, name = "cat") + }) + public abstract static class Animal { + public String name; + } + + public static class Dog extends Animal { + public int barks; + } + + public static class Cat extends Animal { + public int lives; + } + + /** + * Output schema fixture whose two described fields share a subtype union, so every {@code $ref} + * carrying a forbidden sibling sits inside an {@code anyOf} array rather than directly under a + * {@code properties} map. + */ + public static class Owner { + @JsonPropertyDescription("the pet") + public Animal pet; + + @JsonPropertyDescription("the backup pet") + public Animal backup; + } + + /** + * Output schema fixture declaring a property literally named {@code $ref}, which puts a member + * of that name into the enclosing {@code properties} map without making that map a reference. + */ + public static class RefNamedProperty { + @JsonProperty("$ref") + public String reference; + + public String other; + } + + /** + * Output schema fixture whose enums are written under values other than their Java names, one + * through per-constant {@code @JsonProperty} and one through a {@code @JsonValue} method. + */ + public static class Ticket { + public Status status; + public Severity severity; + } + + public enum Status { + @JsonProperty("in-progress") + IN_PROGRESS, + @JsonProperty("done") + DONE + } + + public enum Severity { + LOW("low"), + HIGH("high"); + + private final String wire; + + Severity(String wire) { + this.wire = wire; + } + + @JsonValue + public String wire() { + return wire; + } + } + /** Minimal tool carrying only metadata; never invoked in these tests. */ private static final class SchemaOnlyTool extends Tool { SchemaOnlyTool() { @@ -446,7 +610,8 @@ void buildConfigAppliesSystemInstruction() { List messages = List.of(ChatMessage.system("be terse"), ChatMessage.user("hi")); - GenerateContentConfig config = connection().buildConfig(messages, null, params()); + GenerateContentConfig config = + connection().buildConfig(messages, null, params(), CAPABLE_MODEL, null); Content instruction = config.systemInstruction().orElseThrow(); // Exactly one part: the USER turn must not be lifted into the system instruction. @@ -462,7 +627,7 @@ void buildConfigForwardsAdditionalKwargs() { arguments.put("additional_kwargs", Map.of("top_k", 40, "top_p", 0.9)); GenerateContentConfig config = - connection().buildConfig(List.of(ChatMessage.user("hi")), null, arguments); + connection().buildConfig(userMessage(), null, arguments, CAPABLE_MODEL, null); assertThat(config.topK()).hasValue(40f); assertThat(config.topP()).hasValue(0.9f); @@ -476,7 +641,7 @@ void buildConfigSetsTemperatureAndMaxOutputTokens() { arguments.put("max_output_tokens", 512); GenerateContentConfig config = - connection().buildConfig(List.of(ChatMessage.user("hi")), null, arguments); + connection().buildConfig(userMessage(), null, arguments, CAPABLE_MODEL, null); assertThat(config.temperature()).hasValue(0.25f); assertThat(config.maxOutputTokens()).hasValue(512); @@ -488,9 +653,11 @@ void buildConfigSetsToolsWhenPresent() { GenerateContentConfig config = connection() .buildConfig( - List.of(ChatMessage.user("hi")), + userMessage(), List.of(new SchemaOnlyTool()), - params()); + params(), + CAPABLE_MODEL, + null); List declarations = config.tools().orElseThrow().get(0).functionDeclarations().orElseThrow(); @@ -499,4 +666,303 @@ void buildConfigSetsToolsWhenPresent() { assertThat(declarations.get(0).description()).hasValue("Add two numbers."); assertThat(declarations.get(0).parametersJsonSchema()).hasValue(Map.of("type", "object")); } + + @ParameterizedTest + @ValueSource( + strings = { + "gemini-3.1-pro-preview", + "gemini-3.8-flash", + "gemini-3.5-flash-lite", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-robotics-er-1.6-preview" + }) + @DisplayName("Every live Gemini text model reports native structured-output support") + void supportsNativeStructuredOutputForTextModels(String model) { + assertThat(connection().supportsNativeStructuredOutput(model)).isTrue(); + } + + @ParameterizedTest + @ValueSource( + strings = { + "gemini-3.1-flash-image", + "gemini-2.5-flash-image", + "gemini-2.5-flash-preview-tts", + "gemini-2.5-flash-native-audio-preview-12-2025", + "gemini-3.1-flash-live-preview", + "gemini-3.5-transcribe", + "gemini-embedding-001", + "gemini-omni-flash" + }) + @DisplayName("A non-text output modality is rejected even though it carries the family prefix") + void supportsNativeStructuredOutputRejectsNonTextModalities(String model) { + // gemini-2.5-flash-image is the case the marker exists for: its published capability row + // claims support, and the service answers 400 "JSON mode is not enabled for this model". + assertThat(connection().supportsNativeStructuredOutput(model)).isFalse(); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "gemini-"}) + @DisplayName("A null, blank or bare-prefix model reports not-capable") + void supportsNativeStructuredOutputRejectsNullBlankAndBarePrefix(String model) { + assertThat(connection().supportsNativeStructuredOutput(model)).isFalse(); + } + + @ParameterizedTest + @ValueSource( + strings = { + "models/gemini-2.5-flash", + "gemma-4-31b-it", + "tunedModels/my-tune", + "gemini", + "imagen-4.0-generate-001" + }) + @DisplayName("A name outside the family reports not-capable and keeps the prompt fallback") + void supportsNativeStructuredOutputRejectsOutsideFamily(String model) { + assertThat(connection().supportsNativeStructuredOutput(model)).isFalse(); + } + + @Test + @DisplayName("A POJO schema is sent as responseJsonSchema alongside a JSON response mime type") + void nativeSchemaAppliedForPojo() { + GenerateContentConfig config = + connection() + .buildConfig(userMessage(), null, params(), CAPABLE_MODEL, Report.class); + + assertThat(config.responseMimeType()).hasValue("application/json"); + JsonNode schema = nativeSchema(config); + assertThat(schema.path("type").asText()).isEqualTo("object"); + assertThat(fieldNames(schema.path("properties"))) + .containsExactly("summary", "counts", "note", "total"); + } + + @Test + @DisplayName("A RowTypeInfo-shaped schema is skipped rather than rejected") + void nativeSchemaSkippedForRowTypeInfo() { + // A RowTypeInfo schema arrives wrapped in OutputSchema rather than as a bare POJO Class, + // so it must not activate native structured output. OutputSchema cannot be instantiated + // here because RowTypeInfo is not on this module's classpath; any non-Class schema object + // exercises the same gate. + Object nonClassSchema = "row"; + + GenerateContentConfig config = + connection() + .buildConfig(userMessage(), null, params(), CAPABLE_MODEL, nonClassSchema); + + assertThat(config.responseJsonSchema()).isEmpty(); + assertThat(config.responseMimeType()).isEmpty(); + } + + @Test + @DisplayName("A request carrying tools keeps the tools and drops the schema") + void nativeSchemaSkippedWhenToolsPresent() { + // Outside a documented preview, Gemini answers a request combining function declarations + // with a JSON response mime type with 400 INVALID_ARGUMENT, so the schema degrades to the + // prompt fallback rather than failing the whole request. + GenerateContentConfig config = + connection() + .buildConfig( + userMessage(), + List.of(new SchemaOnlyTool()), + params(), + CAPABLE_MODEL, + Report.class); + + assertThat(config.tools()).isPresent(); + assertThat(config.responseJsonSchema()).isEmpty(); + assertThat(config.responseMimeType()).isEmpty(); + } + + @Test + @DisplayName("A model without documented support is never sent a schema") + void nativeSchemaSkippedForIncapableModel() { + GenerateContentConfig config = + connection() + .buildConfig( + userMessage(), + null, + params(), + "gemini-2.5-flash-image", + Report.class); + + assertThat(config.responseJsonSchema()).isEmpty(); + assertThat(config.responseMimeType()).isEmpty(); + } + + @Test + @DisplayName("No output schema leaves the response format unconstrained") + void nullSchemaLeavesRequestUnconstrained() { + GenerateContentConfig config = + connection().buildConfig(userMessage(), null, params(), CAPABLE_MODEL, null); + + assertThat(config.responseJsonSchema()).isEmpty(); + assertThat(config.responseMimeType()).isEmpty(); + } + + @Test + @DisplayName("The derived schema names properties the way Jackson deserializes them") + void derivedSchemaHonorsJacksonAnnotations() { + // The response is read back with an ObjectMapper, which accepts the renamed property and + // rejects the Java field name, and which discards an ignored property the schema would + // otherwise force the model to fabricate. + GenerateContentConfig config = + connection() + .buildConfig(userMessage(), null, params(), CAPABLE_MODEL, Profile.class); + + assertThat(fieldNames(nativeSchema(config).path("properties"))) + .containsExactly("full_name", "age"); + } + + @Test + @DisplayName("The derived schema lists enum constants by the values Jackson deserializes") + void derivedSchemaListsEnumsByTheirJacksonWireValues() throws Exception { + GenerateContentConfig config = + connection() + .buildConfig(userMessage(), null, params(), CAPABLE_MODEL, Ticket.class); + JsonNode properties = nativeSchema(config).path("properties"); + JsonNode statusValues = properties.path("status").path("enum"); + JsonNode severityValues = properties.path("severity").path("enum"); + + assertThat(statusValues.toString()).isEqualTo("[\"in-progress\",\"done\"]"); + assertThat(severityValues.toString()).isEqualTo("[\"low\",\"high\"]"); + // A response built from the permitted values must read back with the plain ObjectMapper + // the structured-output read-back uses. + String response = + "{\"status\":" + + statusValues.get(0) + + ",\"severity\":" + + severityValues.get(1) + + "}"; + Ticket ticket = new ObjectMapper().readValue(response, Ticket.class); + assertThat(ticket.status).isEqualTo(Status.IN_PROGRESS); + assertThat(ticket.severity).isEqualTo(Severity.HIGH); + } + + @Test + @DisplayName("The derived schema closes objects without unsetting a map's value schema") + void derivedSchemaClosesObjects() { + GenerateContentConfig config = + connection() + .buildConfig(userMessage(), null, params(), CAPABLE_MODEL, Report.class); + JsonNode schema = nativeSchema(config); + + // No Gemini document states that a schema without the keyword is closed, so an undeclared + // key the ObjectMapper then rejects is admissible unless the schema says otherwise. + assertThat(schema.path("additionalProperties").isBoolean()).isTrue(); + assertThat(schema.path("additionalProperties").asBoolean()).isFalse(); + // The closure applies to the enclosing object, never to a map's declared value type. + assertThat( + schema.path("properties") + .path("counts") + .path("additionalProperties") + .path("type") + .asText()) + .isEqualTo("integer"); + } + + @Test + @DisplayName("The derived schema requires every field the caller did not declare omissible") + void derivedSchemaMarksNonOptionalFieldsRequired() { + // Gemini treats a field the schema does not list as required as one the model may skip, + // so leaving `required` unset would let a response omit fields at will. + GenerateContentConfig config = + connection() + .buildConfig(userMessage(), null, params(), CAPABLE_MODEL, Report.class); + + List required = new ArrayList<>(); + nativeSchema(config).path("required").forEach(entry -> required.add(entry.asText())); + assertThat(required).containsExactlyInAnyOrder("summary", "counts", "total"); + } + + @Test + @DisplayName("A $ref carries no sibling that Gemini forbids beside it") + void derivedSchemaStripsRefSiblings() { + // Gemini states that a sub-schema setting $ref may set no other property except those + // starting with $. A described field whose type is reused is extracted into $defs and + // emits exactly that pairing. + GenerateContentConfig config = + connection() + .buildConfig(userMessage(), null, params(), CAPABLE_MODEL, Addresses.class); + JsonNode properties = nativeSchema(config).path("properties"); + + assertThat(properties.path("home").path("$ref").isTextual()).isTrue(); + assertThat(fieldNames(properties.path("home"))).containsExactly("$ref"); + assertThat(fieldNames(properties.path("work"))).containsExactly("$ref"); + } + + @Test + @DisplayName("A $ref nested inside a $defs entry is stripped too") + void derivedSchemaStripsRefSiblingsInsideDefs() { + // Reusing a type that itself reuses one puts the forbidden pairing four levels down, under + // $defs rather than under the root's properties, so a walk that only visited the root's + // own properties would leave it in place. + GenerateContentConfig config = + connection() + .buildConfig(userMessage(), null, params(), CAPABLE_MODEL, Building.class); + JsonNode nested = nativeSchema(config).path("$defs").path("Addresses").path("properties"); + + assertThat(nested.path("home").path("$ref").isTextual()).isTrue(); + assertThat(fieldNames(nested.path("home"))).containsExactly("$ref"); + assertThat(fieldNames(nested.path("work"))).containsExactly("$ref"); + } + + @Test + @DisplayName("A $ref inside an anyOf array is stripped, so the walk descends through arrays") + void derivedSchemaStripsRefSiblingsInsideAnyOfBranches() { + // A subtype union renders as an anyOf array of $refs, and a description on the declaring + // field is copied onto every branch. These are the only forbidden pairings the generator + // places inside a JSON array rather than inside an object, so a walk that visited object + // members only would leave all four in place. + GenerateContentConfig config = + connection().buildConfig(userMessage(), null, params(), CAPABLE_MODEL, Owner.class); + JsonNode properties = nativeSchema(config).path("properties"); + + for (String field : List.of("pet", "backup")) { + JsonNode branches = properties.path(field).path("anyOf"); + assertThat(branches).hasSize(2); + branches.forEach( + branch -> { + assertThat(branch.path("$ref").isTextual()).isTrue(); + assertThat(fieldNames(branch)).containsExactly("$ref"); + }); + } + } + + @Test + @DisplayName("A property named $ref does not make its enclosing map look like a reference") + void derivedSchemaKeepsSiblingsOfAPropertyNamedRef() { + // The properties map of this class carries a member named $ref whose value is that + // property's own schema, an object. Treating the map as a reference would delete every + // other property from it while required still listed them, leaving a document that + // additionalProperties:false makes unsatisfiable. + GenerateContentConfig config = + connection() + .buildConfig( + userMessage(), + null, + params(), + CAPABLE_MODEL, + RefNamedProperty.class); + JsonNode schema = nativeSchema(config); + + assertThat(fieldNames(schema.path("properties"))).containsExactly("$ref", "other"); + List required = new ArrayList<>(); + schema.path("required").forEach(entry -> required.add(entry.asText())); + assertThat(required).containsExactlyInAnyOrder("$ref", "other"); + } + + @Test + @DisplayName("The schema-less chat overload delegates to the schema-carrying one") + void chatWithoutSchemaDelegatesToTheSchemaCarryingOverload() { + // The three-argument overload holds no body of its own; everything, including the model + // resolution that raises this error, lives in the four-argument one. A three-argument call + // that stopped delegating would never reach it. + GeminiChatModelConnection conn = + new GeminiChatModelConnection(descriptor("test-key", null, null), NOOP); + + assertThatThrownBy(() -> conn.chat(List.of(ChatMessage.user("hi")), null, params())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("model name must be provided"); + } }