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