This is an automated email from the ASF dual-hosted git repository.

wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git


The following commit(s) were added to refs/heads/main by this push:
     new 78707f887 [integrations][gemini] Apply Gemini native structured output 
(#1098)
78707f887 is described below

commit 78707f8875585c4afa5835551effdc1f3a27e650
Author: Weiqing Yang <[email protected]>
AuthorDate: Mon Sep 14 19:00:49 2026 -0700

    [integrations][gemini] Apply Gemini native structured output (#1098)
    
    Generated-by: Claude Code 2.1.270 (Claude Opus 5)
---
 integrations/chat-models/gemini/pom.xml            |  11 +
 .../gemini/GeminiChatModelConnection.java          | 211 ++++++++-
 .../gemini/GeminiChatModelConnectionTest.java      | 476 ++++++++++++++++++++-
 3 files changed, 690 insertions(+), 8 deletions(-)

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.
             <artifactId>google-genai</artifactId>
             <version>${google.genai.version}</version>
         </dependency>
+
+        <!-- Versions managed by the victools BOM imported in the root pom. -->
+        <dependency>
+            <groupId>com.github.victools</groupId>
+            <artifactId>jsonschema-generator</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>com.github.victools</groupId>
+            <artifactId>jsonschema-module-jackson</artifactId>
+        </dependency>
     </dependencies>
 
 </project>
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 @@ package 
org.apache.flink.agents.integrations.chatmodels.gemini;
 
 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.List;
 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<String, Object>> 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<String> 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 class GeminiChatModelConnection extends 
BaseChatModelConnection {
         this.client.close();
     }
 
+    /**
+     * Whether Google documents native structured output for {@code 
effectiveModel}.
+     *
+     * <p>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.
+     *
+     * <p>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.
+     *
+     * <p>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<ChatMessage> messages,
             List<org.apache.flink.agents.api.tools.Tool> tools,
             Map<String, Object> 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.
+     *
+     * <p>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<ChatMessage> messages,
+            List<org.apache.flink.agents.api.tools.Tool> tools,
+            Map<String, Object> arguments,
+            Object outputSchema) {
         Map<String, Object> args = arguments != null ? new 
HashMap<>(arguments) : new HashMap<>();
 
         Object modelObj = args.remove("model");
@@ -202,7 +291,8 @@ public class GeminiChatModelConnection extends 
BaseChatModelConnection {
                             .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 @@ public class GeminiChatModelConnection extends 
BaseChatModelConnection {
         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<ChatMessage> messages,
             List<org.apache.flink.agents.api.tools.Tool> tools,
-            Map<String, Object> arguments) {
+            Map<String, Object> arguments,
+            String modelName,
+            Object outputSchema) {
         GenerateContentConfig.Builder builder = 
GenerateContentConfig.builder();
 
         Content systemInstruction = extractSystemInstruction(messages);
@@ -284,9 +378,120 @@ public class GeminiChatModelConnection extends 
BaseChatModelConnection {
             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<String> 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<String, Object> kwargs) {
         for (Map.Entry<String, Object> 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.ToolResponse;
 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 @@ class GeminiChatModelConnectionTest {
         return new HashMap<>();
     }
 
+    private static List<ChatMessage> userMessage() {
+        return List.of(ChatMessage.user("hi"));
+    }
+
+    private static JsonNode nativeSchema(GenerateContentConfig config) {
+        return (JsonNode) config.responseJsonSchema().orElseThrow();
+    }
+
+    private static List<String> fieldNames(JsonNode node) {
+        List<String> 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<String, Integer> counts;
+        public Optional<String> note;
+        public int total;
+    }
+
+    /**
+     * Output schema fixture shaped to expose Jackson's property model.
+     *
+     * <p>{@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 @@ class GeminiChatModelConnectionTest {
         List<ChatMessage> 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 @@ class GeminiChatModelConnectionTest {
         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 @@ class GeminiChatModelConnectionTest {
         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 @@ class GeminiChatModelConnectionTest {
         GenerateContentConfig config =
                 connection()
                         .buildConfig(
-                                List.of(ChatMessage.user("hi")),
+                                userMessage(),
                                 List.of(new SchemaOnlyTool()),
-                                params());
+                                params(),
+                                CAPABLE_MODEL,
+                                null);
 
         List<FunctionDeclaration> declarations =
                 
config.tools().orElseThrow().get(0).functionDeclarations().orElseThrow();
@@ -499,4 +666,303 @@ class GeminiChatModelConnectionTest {
         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<name STRING>";
+
+        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<String> 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<String> 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");
+    }
 }

Reply via email to