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");
+ }
}